@atelic-action/ui 0.17.2 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +432 -45
- 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/Note.tsx +9 -39
- package/src/artifact/Paragraph.tsx +65 -0
- package/src/artifact/Read.tsx +406 -0
- package/src/artifact/Writeup.tsx +7 -7
- package/src/artifact/index.ts +1 -0
- 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 +759 -401
- 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,20 @@
|
|
|
1
|
+
import { useWithBase } from "../routing/BasePathProvider";
|
|
2
|
+
|
|
3
|
+
export interface MediaBandProps {
|
|
4
|
+
image: { src: string; alt: string };
|
|
5
|
+
/**
|
|
6
|
+
* CSS aspect-ratio for the band (viewport-width : height), e.g. "8/3" for
|
|
7
|
+
* a wide strip. The image covers the band and crops from center.
|
|
8
|
+
*/
|
|
9
|
+
aspectRatio?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Full-bleed image band: edge to edge, no frame, no radius. */
|
|
13
|
+
export function MediaBand({ image, aspectRatio = "8/3" }: MediaBandProps) {
|
|
14
|
+
const withBase = useWithBase();
|
|
15
|
+
return (
|
|
16
|
+
<div className="media-band" style={{ aspectRatio }}>
|
|
17
|
+
<img src={withBase(image.src)} alt={image.alt} loading="lazy" />
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A priced menu group: uppercase heading over dotted rows of name, optional
|
|
5
|
+
* note, and price, with an optional fine-print foot line. Composes into a
|
|
6
|
+
* page column; pair with Menu structured data in the route's head().
|
|
7
|
+
* Styles live in menu-list.css beside it, with the `.menu-stack` and
|
|
8
|
+
* `.menu-cols` layouts a page stacks its groups in.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface MenuListItem {
|
|
12
|
+
name: string;
|
|
13
|
+
/** Modifier or description line under the name. */
|
|
14
|
+
note?: string;
|
|
15
|
+
/** Rendered as text so ranges ("Small 5 / Large 6") stay expressible. */
|
|
16
|
+
price: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface MenuListProps {
|
|
20
|
+
heading: string;
|
|
21
|
+
items: MenuListItem[];
|
|
22
|
+
/** Fine print after the rows, e.g. "All sandwiches available as a salad." */
|
|
23
|
+
foot?: ReactNode;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function MenuList({ heading, items, foot }: MenuListProps) {
|
|
27
|
+
return (
|
|
28
|
+
<div className="menu-list">
|
|
29
|
+
<h3>{heading}</h3>
|
|
30
|
+
<ul>
|
|
31
|
+
{items.map((item) => (
|
|
32
|
+
<li key={item.name}>
|
|
33
|
+
<span className="ml-item">
|
|
34
|
+
<span className="ml-name">{item.name}</span>
|
|
35
|
+
{item.note && <span className="ml-note">{item.note}</span>}
|
|
36
|
+
</span>
|
|
37
|
+
<span className="ml-price">{item.price}</span>
|
|
38
|
+
</li>
|
|
39
|
+
))}
|
|
40
|
+
</ul>
|
|
41
|
+
{foot && <p className="ml-foot">{foot}</p>}
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { Eyebrow, Lead } from "../primitives";
|
|
3
|
+
|
|
4
|
+
export interface PageHeroProps {
|
|
5
|
+
eyebrow?: string;
|
|
6
|
+
title: ReactNode;
|
|
7
|
+
lead?: ReactNode;
|
|
8
|
+
center?: boolean;
|
|
9
|
+
/** Extra content under the lead (e.g. jump-link chips). */
|
|
10
|
+
children?: ReactNode;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Interior page opener on the alternate surface band. */
|
|
14
|
+
export function PageHero({ eyebrow, title, lead, center = false, children }: PageHeroProps) {
|
|
15
|
+
return (
|
|
16
|
+
<section className={["page-hero", center && "center"].filter(Boolean).join(" ")}>
|
|
17
|
+
<div className="wrap">
|
|
18
|
+
{eyebrow && <Eyebrow center={center}>{eyebrow}</Eyebrow>}
|
|
19
|
+
<h1>{title}</h1>
|
|
20
|
+
{lead && <Lead style={center ? { marginInline: "auto" } : undefined}>{lead}</Lead>}
|
|
21
|
+
{children}
|
|
22
|
+
</div>
|
|
23
|
+
</section>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export interface PressItem {
|
|
2
|
+
/** The quote or award title, e.g. "Best Coffeehouse for Food". */
|
|
3
|
+
quote: string;
|
|
4
|
+
/** Publication name, e.g. "Westword". */
|
|
5
|
+
source: string;
|
|
6
|
+
year: string | number;
|
|
7
|
+
/** Link to the article; rows without one render unlinked. */
|
|
8
|
+
href?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface PressQuotesProps {
|
|
12
|
+
items: PressItem[];
|
|
13
|
+
/** Display order by year; newest first unless told otherwise. */
|
|
14
|
+
order?: "newest" | "oldest";
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Sort press items by year; exported for tests. */
|
|
19
|
+
export function sortPress(items: PressItem[], order: "newest" | "oldest"): PressItem[] {
|
|
20
|
+
const dir = order === "newest" ? -1 : 1;
|
|
21
|
+
return [...items].sort((a, b) => dir * (Number(a.year) - Number(b.year)));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Rule separated rows of press quotes and awards, each citing its publication and year and linking to the article. */
|
|
25
|
+
export function PressQuotes({ items, order = "newest", className }: PressQuotesProps) {
|
|
26
|
+
return (
|
|
27
|
+
<ul className={["press-quotes", className].filter(Boolean).join(" ")}>
|
|
28
|
+
{sortPress(items, order).map((item) => {
|
|
29
|
+
const body = (
|
|
30
|
+
<>
|
|
31
|
+
<span className="pq-quote">{item.quote}</span>
|
|
32
|
+
<span className="pq-src">
|
|
33
|
+
{item.source} · {item.year}
|
|
34
|
+
</span>
|
|
35
|
+
</>
|
|
36
|
+
);
|
|
37
|
+
return (
|
|
38
|
+
<li key={item.source + item.year + item.quote}>
|
|
39
|
+
{item.href ? (
|
|
40
|
+
<a className="pq-row" href={item.href} target="_blank" rel="noopener noreferrer">
|
|
41
|
+
{body}
|
|
42
|
+
</a>
|
|
43
|
+
) : (
|
|
44
|
+
<div className="pq-row">{body}</div>
|
|
45
|
+
)}
|
|
46
|
+
</li>
|
|
47
|
+
);
|
|
48
|
+
})}
|
|
49
|
+
</ul>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { Eyebrow } from "../primitives";
|
|
3
|
+
import { useWithBase } from "../routing/BasePathProvider";
|
|
4
|
+
|
|
5
|
+
export interface PricingBandProps {
|
|
6
|
+
eyebrow?: string;
|
|
7
|
+
title: ReactNode;
|
|
8
|
+
lead?: ReactNode;
|
|
9
|
+
price: string;
|
|
10
|
+
/** Unit line under the price, e.g. "/ 60 minutes". */
|
|
11
|
+
unit: string;
|
|
12
|
+
/** Detail link under the price, e.g. to the pricing page. */
|
|
13
|
+
detail?: { label: string; href: string };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Brand-colored pricing band with an inline price card. Informational by
|
|
18
|
+
* design — the page's booking action belongs to the closing CTASection,
|
|
19
|
+
* so pages never pitch the primary CTA twice.
|
|
20
|
+
*/
|
|
21
|
+
export function PricingBand({ eyebrow, title, lead, price, unit, detail }: PricingBandProps) {
|
|
22
|
+
const withBase = useWithBase();
|
|
23
|
+
return (
|
|
24
|
+
<section className="price-band">
|
|
25
|
+
<div className="wrap price-inner">
|
|
26
|
+
<div>
|
|
27
|
+
{eyebrow && <Eyebrow onDark>{eyebrow}</Eyebrow>}
|
|
28
|
+
<h2>{title}</h2>
|
|
29
|
+
{lead && <p className="lead price-lead">{lead}</p>}
|
|
30
|
+
</div>
|
|
31
|
+
<div className="price-card">
|
|
32
|
+
<span className="price-fig">{price}</span>
|
|
33
|
+
<span className="price-unit">{unit}</span>
|
|
34
|
+
{detail && (
|
|
35
|
+
<a className="price-detail" href={withBase(detail.href)}>
|
|
36
|
+
{detail.label} →
|
|
37
|
+
</a>
|
|
38
|
+
)}
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
</section>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { Button } from "../primitives/Button";
|
|
3
|
+
import { TickList } from "../primitives/TickList";
|
|
4
|
+
import { useWithBase } from "../routing/BasePathProvider";
|
|
5
|
+
|
|
6
|
+
export interface PricingCardProps {
|
|
7
|
+
/** Small mono line above the price, e.g. "Every session · every service". */
|
|
8
|
+
ribbon?: string;
|
|
9
|
+
price: string;
|
|
10
|
+
/** Line under the price, e.g. "for a full 60 minutes". */
|
|
11
|
+
per: string;
|
|
12
|
+
ticks?: ReactNode[];
|
|
13
|
+
cta: { label: string; href: string; newTab?: boolean };
|
|
14
|
+
secondary?: { label: string; href: string };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Standalone detailed pricing card (the pricing page centerpiece). */
|
|
18
|
+
export function PricingCard({ ribbon, price, per, ticks, cta, secondary }: PricingCardProps) {
|
|
19
|
+
const withBase = useWithBase();
|
|
20
|
+
return (
|
|
21
|
+
<div className="cost-card">
|
|
22
|
+
{ribbon && <span className="ribbon">{ribbon}</span>}
|
|
23
|
+
<div className="big">{price}</div>
|
|
24
|
+
<div className="per">{per}</div>
|
|
25
|
+
{ticks && ticks.length > 0 && <TickList items={ticks} />}
|
|
26
|
+
<Button href={cta.href} newTab={cta.newTab} size="lg" arrow style={{ width: "100%" }}>
|
|
27
|
+
{cta.label}
|
|
28
|
+
</Button>
|
|
29
|
+
{secondary && (
|
|
30
|
+
<a className="cost-secondary" href={withBase(secondary.href)}>
|
|
31
|
+
{secondary.label} →
|
|
32
|
+
</a>
|
|
33
|
+
)}
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { Eyebrow } from "../primitives";
|
|
3
|
+
import { ImageFrame } from "../primitives/ImageFrame";
|
|
4
|
+
|
|
5
|
+
export interface ProcessStep {
|
|
6
|
+
title: string;
|
|
7
|
+
description: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ProcessStepsProps {
|
|
11
|
+
eyebrow?: string;
|
|
12
|
+
title: ReactNode;
|
|
13
|
+
steps: ProcessStep[];
|
|
14
|
+
image?: { src: string; alt: string };
|
|
15
|
+
/** Placeholder label rendered in the media frame until a real image exists. */
|
|
16
|
+
placeholder?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Dark band with numbered steps and supporting media. */
|
|
20
|
+
export function ProcessSteps({ eyebrow, title, steps, image, placeholder }: ProcessStepsProps) {
|
|
21
|
+
return (
|
|
22
|
+
<section className="section process">
|
|
23
|
+
<div className="wrap process-grid">
|
|
24
|
+
<div>
|
|
25
|
+
{eyebrow && <Eyebrow onDark>{eyebrow}</Eyebrow>}
|
|
26
|
+
<h2>{title}</h2>
|
|
27
|
+
<ol className="steps">
|
|
28
|
+
{steps.map((step, i) => (
|
|
29
|
+
<li key={step.title}>
|
|
30
|
+
<span className="step-n">{i + 1}</span>
|
|
31
|
+
<div>
|
|
32
|
+
<h4>{step.title}</h4>
|
|
33
|
+
<p>{step.description}</p>
|
|
34
|
+
</div>
|
|
35
|
+
</li>
|
|
36
|
+
))}
|
|
37
|
+
</ol>
|
|
38
|
+
</div>
|
|
39
|
+
<div className="process-media">
|
|
40
|
+
<ImageFrame src={image?.src} alt={image?.alt} placeholder={placeholder} />
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
</section>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { SectionHeading } from "../primitives";
|
|
3
|
+
import { useWithBase } from "../routing/BasePathProvider";
|
|
4
|
+
|
|
5
|
+
export interface ProfileItem {
|
|
6
|
+
name: string;
|
|
7
|
+
/** One or two sentence bio; keep it shorter than a FeatureDetail lead. */
|
|
8
|
+
blurb: ReactNode;
|
|
9
|
+
/** Example of their work or a portrait. */
|
|
10
|
+
image?: { src: string; alt: string };
|
|
11
|
+
/** Placeholder label rendered until a real image exists. */
|
|
12
|
+
placeholder?: string;
|
|
13
|
+
/** Where the card links (e.g. the person's section on a roster page). */
|
|
14
|
+
href?: string;
|
|
15
|
+
linkLabel?: string;
|
|
16
|
+
/** Small mono line under the name (e.g. an Instagram handle). */
|
|
17
|
+
meta?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ProfileCardsProps {
|
|
21
|
+
id?: string;
|
|
22
|
+
eyebrow?: string;
|
|
23
|
+
title: ReactNode;
|
|
24
|
+
lead?: ReactNode;
|
|
25
|
+
items: ProfileItem[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Equal-weight horizontal profile cards: media beside a short bio, no
|
|
30
|
+
* numbering. For rosters and teams where every member carries the same
|
|
31
|
+
* weight (contrast FeatureGrid, whose numbered cards imply an ordering).
|
|
32
|
+
*/
|
|
33
|
+
export function ProfileCards({ id, eyebrow, title, lead, items }: ProfileCardsProps) {
|
|
34
|
+
const withBase = useWithBase();
|
|
35
|
+
return (
|
|
36
|
+
<section className="section" id={id}>
|
|
37
|
+
<div className="wrap">
|
|
38
|
+
<SectionHeading eyebrow={eyebrow} title={title} lead={lead} />
|
|
39
|
+
<ul className="profile-cards">
|
|
40
|
+
{items.map((item) => {
|
|
41
|
+
const media = item.image ? (
|
|
42
|
+
<img src={withBase(item.image.src)} alt={item.image.alt} loading="lazy" />
|
|
43
|
+
) : (
|
|
44
|
+
<span className="profile-placeholder">{item.placeholder ?? "Photo"}</span>
|
|
45
|
+
);
|
|
46
|
+
const body = (
|
|
47
|
+
<>
|
|
48
|
+
<div className="profile-media">{media}</div>
|
|
49
|
+
<div className="profile-body">
|
|
50
|
+
<h3>{item.name}</h3>
|
|
51
|
+
{item.meta && <span className="profile-meta">{item.meta}</span>}
|
|
52
|
+
<p>{item.blurb}</p>
|
|
53
|
+
{item.href && <span className="profile-link">{item.linkLabel ?? "More"} →</span>}
|
|
54
|
+
</div>
|
|
55
|
+
</>
|
|
56
|
+
);
|
|
57
|
+
return (
|
|
58
|
+
<li key={item.name}>
|
|
59
|
+
{item.href ? (
|
|
60
|
+
<a className="profile-card" href={withBase(item.href)}>
|
|
61
|
+
{body}
|
|
62
|
+
</a>
|
|
63
|
+
) : (
|
|
64
|
+
<div className="profile-card">{body}</div>
|
|
65
|
+
)}
|
|
66
|
+
</li>
|
|
67
|
+
);
|
|
68
|
+
})}
|
|
69
|
+
</ul>
|
|
70
|
+
</div>
|
|
71
|
+
</section>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
2
|
+
import {
|
|
3
|
+
type CSSProperties,
|
|
4
|
+
type KeyboardEvent,
|
|
5
|
+
type PointerEvent,
|
|
6
|
+
useCallback,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
} from "react";
|
|
10
|
+
import { useWithBase } from "../routing/BasePathProvider";
|
|
11
|
+
|
|
12
|
+
export interface RevealSlide {
|
|
13
|
+
/** The image. Omit it and pass `placeholder` until the real shot exists. */
|
|
14
|
+
src?: string;
|
|
15
|
+
alt?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Shot brief rendered as a labeled block when there is no `src` yet, e.g.
|
|
18
|
+
* "Screenshot: the old home page on a phone". The two sides render in
|
|
19
|
+
* different tones, so dragging still moves something visible.
|
|
20
|
+
*/
|
|
21
|
+
placeholder?: string;
|
|
22
|
+
/** Corner label, e.g. "Today" or "The rebuild". */
|
|
23
|
+
label: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface RevealSliderProps {
|
|
27
|
+
/** The current-state image, revealed on the left. */
|
|
28
|
+
before: RevealSlide;
|
|
29
|
+
/** The rebuilt-state image, the base layer on the right. */
|
|
30
|
+
after: RevealSlide;
|
|
31
|
+
/** Starting divider position, 0-100. Default 50. */
|
|
32
|
+
startAt?: number;
|
|
33
|
+
/** Frame aspect ratio; both images should match it. Default "16/10". */
|
|
34
|
+
aspectRatio?: string;
|
|
35
|
+
/**
|
|
36
|
+
* Accessible name for the drag handle. Defaults to a neutral before and
|
|
37
|
+
* after, because what the two states are is the page's business rather
|
|
38
|
+
* than the component's. Name them when the page can be specific.
|
|
39
|
+
*/
|
|
40
|
+
ariaLabel?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** One side of the frame: the photograph, or its shot brief until one exists. */
|
|
44
|
+
function Layer({
|
|
45
|
+
slide,
|
|
46
|
+
side,
|
|
47
|
+
style,
|
|
48
|
+
}: {
|
|
49
|
+
slide: RevealSlide;
|
|
50
|
+
side: "before" | "after";
|
|
51
|
+
style?: CSSProperties;
|
|
52
|
+
}) {
|
|
53
|
+
const withBase = useWithBase();
|
|
54
|
+
const base = ["rslider-layer", side === "before" && "rslider-before"].filter(Boolean).join(" ");
|
|
55
|
+
if (!slide.src) {
|
|
56
|
+
return (
|
|
57
|
+
<div className={`${base} rslider-ph is-${side}`} style={style}>
|
|
58
|
+
<span>{slide.placeholder ?? slide.label}</span>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return (
|
|
63
|
+
<img
|
|
64
|
+
className={`${base} rslider-img`}
|
|
65
|
+
src={withBase(slide.src)}
|
|
66
|
+
alt={slide.alt ?? ""}
|
|
67
|
+
draggable={false}
|
|
68
|
+
style={style}
|
|
69
|
+
/>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Drag-to-compare before/after slider. The rebuilt image is the base layer;
|
|
75
|
+
* the current image is masked with clip-path so dragging never distorts either
|
|
76
|
+
* one. SSR-safe: prerenders at `startAt`, then hydrates the pointer + keyboard
|
|
77
|
+
* handles. Reusable across any before/after reveal.
|
|
78
|
+
*
|
|
79
|
+
* A side with no `src` renders its shot brief instead, so a case study or a
|
|
80
|
+
* project page can be composed before the photography exists.
|
|
81
|
+
*/
|
|
82
|
+
export function RevealSlider({
|
|
83
|
+
before,
|
|
84
|
+
after,
|
|
85
|
+
startAt = 50,
|
|
86
|
+
aspectRatio = "16/10",
|
|
87
|
+
ariaLabel = "Drag to compare before and after",
|
|
88
|
+
}: RevealSliderProps) {
|
|
89
|
+
const [pos, setPos] = useState(startAt);
|
|
90
|
+
const frameRef = useRef<HTMLDivElement>(null);
|
|
91
|
+
const dragging = useRef(false);
|
|
92
|
+
|
|
93
|
+
const setFromClientX = useCallback((clientX: number) => {
|
|
94
|
+
const el = frameRef.current;
|
|
95
|
+
if (!el) return;
|
|
96
|
+
const rect = el.getBoundingClientRect();
|
|
97
|
+
const next = ((clientX - rect.left) / rect.width) * 100;
|
|
98
|
+
setPos(Math.max(0, Math.min(100, next)));
|
|
99
|
+
}, []);
|
|
100
|
+
|
|
101
|
+
const onPointerDown = (e: PointerEvent<HTMLButtonElement>) => {
|
|
102
|
+
dragging.current = true;
|
|
103
|
+
e.currentTarget.setPointerCapture?.(e.pointerId);
|
|
104
|
+
setFromClientX(e.clientX);
|
|
105
|
+
};
|
|
106
|
+
const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {
|
|
107
|
+
if (dragging.current) setFromClientX(e.clientX);
|
|
108
|
+
};
|
|
109
|
+
const endDrag = () => {
|
|
110
|
+
dragging.current = false;
|
|
111
|
+
};
|
|
112
|
+
const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
|
|
113
|
+
if (e.key === "ArrowLeft") {
|
|
114
|
+
setPos((p) => Math.max(0, p - 3));
|
|
115
|
+
e.preventDefault();
|
|
116
|
+
} else if (e.key === "ArrowRight") {
|
|
117
|
+
setPos((p) => Math.min(100, p + 3));
|
|
118
|
+
e.preventDefault();
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<div
|
|
124
|
+
className="rslider"
|
|
125
|
+
style={{ aspectRatio }}
|
|
126
|
+
ref={frameRef}
|
|
127
|
+
onPointerMove={onPointerMove}
|
|
128
|
+
onPointerUp={endDrag}
|
|
129
|
+
onPointerLeave={endDrag}
|
|
130
|
+
onPointerCancel={endDrag}
|
|
131
|
+
>
|
|
132
|
+
<Layer slide={after} side="after" />
|
|
133
|
+
<Layer slide={before} side="before" style={{ clipPath: `inset(0 ${100 - pos}% 0 0)` }} />
|
|
134
|
+
<span className="rslider-label rslider-label-before">{before.label}</span>
|
|
135
|
+
<span className="rslider-label rslider-label-after">{after.label}</span>
|
|
136
|
+
<div className="rslider-line" style={{ left: `${pos}%` }} aria-hidden />
|
|
137
|
+
<button
|
|
138
|
+
type="button"
|
|
139
|
+
className="rslider-handle"
|
|
140
|
+
style={{ left: `${pos}%` }}
|
|
141
|
+
role="slider"
|
|
142
|
+
aria-label={ariaLabel}
|
|
143
|
+
aria-valuemin={0}
|
|
144
|
+
aria-valuemax={100}
|
|
145
|
+
aria-valuenow={Math.round(pos)}
|
|
146
|
+
onPointerDown={onPointerDown}
|
|
147
|
+
onKeyDown={onKeyDown}
|
|
148
|
+
>
|
|
149
|
+
<span className="rslider-grip">
|
|
150
|
+
<ChevronLeft size={16} aria-hidden />
|
|
151
|
+
<ChevronRight size={16} aria-hidden />
|
|
152
|
+
</span>
|
|
153
|
+
</button>
|
|
154
|
+
</div>
|
|
155
|
+
);
|
|
156
|
+
}
|