@coralreefventures/site 0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Coral Reef Ventures, LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,114 @@
1
+ # @coralreefventures/site
2
+
3
+ The shell a Coral Reef public site is built in: the frame every page shares (skip link, header, main, footer), the
4
+ section and split layouts, the closing call to action, the screenshot frame, the status tag, the social-card builder,
5
+ the metadata helpers and the server-only test helper. Everything is a server component: nothing here is a client
6
+ module or imports one, so a page built on it can ship no script at all. Mantine is never imported; the shell reads
7
+ the theme's CSS variables (`--mantine-primary-color-*`, radii, font sizes) and the site's own tokens.
8
+
9
+ Driftline's `apps/site` began as a copy of Streamlane's, and the two drifted. What differs between the sites as a
10
+ product difference (name, URL, navigation, actions, footer lines, the brand's mark, the cards) is a `SiteConfig`
11
+ the product passes once; what differed by drift was settled here, one way, and is listed at the end.
12
+
13
+ ## Using it
14
+
15
+ Bind the shell to the site's config in one module, and import from there:
16
+
17
+ ```tsx
18
+ // lib/site.tsx
19
+ import { createSite } from "@coralreefventures/site";
20
+ import { Lockup } from "@/components/brand/Lockup";
21
+ import { socialMark } from "@/components/brand/social-mark";
22
+ import { brand } from "@streamlane/theme";
23
+
24
+ export const site = createSite({
25
+ name: "Streamlane",
26
+ url: "https://streamlane.app",
27
+ description: "Docs, work, ideas, and delivery in one place. Delivery state comes from GitHub, never from a person.",
28
+ nav: [
29
+ { key: "product", label: "Product", href: "/product/" },
30
+ { key: "docs", label: "Docs", href: "/docs/" },
31
+ // ...
32
+ ],
33
+ lockup: <Lockup height={24} />,
34
+ actions: [
35
+ { label: "Sign in", href: "https://app.streamlane.app", kind: "link", hideBelow: "md" },
36
+ { label: "Start free", href: "/pricing/", kind: "primary" },
37
+ ],
38
+ footer: {
39
+ maker: <>Streamlane · made by <a href="https://coralreefventures.com/">Coral Reef Ventures</a> · source on <a href="...">GitHub</a></>,
40
+ links: [{ label: "Docs", href: "/docs/" }, { label: "Changelog", href: "/changelog/" } /* ... */],
41
+ trademarks: ["Jira", "Confluence", "Linear", "Notion", "GitLab", "Plane"],
42
+ },
43
+ cards: { "/": { headline: "...", sentence: "..." }, "/pricing/": { headline: "..." } },
44
+ socialCard: { background: brand.ink, text: brand.white, muted: brand.paper, accent: brand.signal, mark: socialMark },
45
+ icons: { icon: [{ url: "/brand/favicon-32.png", sizes: "32x32", type: "image/png" }] },
46
+ themeColor: brand.ink,
47
+ });
48
+
49
+ export const { SiteFrame, pageMetadata } = site;
50
+ ```
51
+
52
+ ```tsx
53
+ // app/(site)/pricing/page.tsx
54
+ import { page, Section } from "@coralreefventures/site";
55
+ import { pageMetadata, SiteFrame } from "@/lib/site";
56
+
57
+ export const metadata = pageMetadata({ title: "Pricing", description: "...", path: "/pricing/" });
58
+
59
+ const PricingPage = () => (
60
+ <SiteFrame active="pricing">
61
+ <Section size="hero">
62
+ <h1 className={page.title}>One plan.</h1>
63
+ </Section>
64
+ </SiteFrame>
65
+ );
66
+ ```
67
+
68
+ `active` is typed to the config's nav keys. The root layout takes `site.rootMetadata()` and `site.rootViewport()`;
69
+ `app/robots.ts` returns `site.robots()`, `app/sitemap.ts` returns `site.sitemap()` (with the docs pages added, for a
70
+ site that has them); and `app/og/[...card]/route.tsx` keeps its two literals and takes the rest:
71
+
72
+ ```ts
73
+ export const dynamic = "force-static";
74
+ export const dynamicParams = false;
75
+ export const { generateStaticParams, GET } = site.socialCardRoute();
76
+ ```
77
+
78
+ Import `@coralreefventures/site/base.css` in the root layout before the site's own `globals.css`. It carries the
79
+ shared rules (body, inline links, focus ring, skip link) and the layout tokens; `globals.css` keeps the colors:
80
+ `--site-text`, `--site-muted`, `--site-border`, `--site-paper`, `--site-link-underline`, `--site-shadow`, and for
81
+ the status tag's attention tone `--site-attention-tint`, `--site-attention-edge`, `--site-attention-text`.
82
+
83
+ ## Installing
84
+
85
+ Ships TypeScript and CSS modules as source, with `exports` pointing at `src/`, and no build step. Add it to Next's
86
+ `transpilePackages` beside the product's theme:
87
+
88
+ ```ts
89
+ transpilePackages: ["@streamlane/theme", "@coralreefventures/site"],
90
+ ```
91
+
92
+ `react`, `react-dom`, `next` and `@mantine/core` are peers at the versions both sites use. Nothing here imports
93
+ Mantine; the peer says which theme variables the stylesheets expect.
94
+
95
+ ## The server-only rule
96
+
97
+ `@coralreefventures/site/testing` exports `clientReach(entry, { root })`, the walk both sites' `server-only.test.ts`
98
+ ran: every "use client" module and every import of a client package (`@mantine/*`, `next/link`, `next/image`,
99
+ `next/script`, `next/dynamic`) reachable from an entry. A product's test keeps asserting it is empty for the root
100
+ layout and the 404 page, and not empty for a page that uses Mantine. This package's own test runs it over every file
101
+ in `src/`.
102
+
103
+ ## What was settled, where the two sites had drifted
104
+
105
+ - The status tag is plain by default with an `attention` tone (Driftline's), rather than always colored
106
+ (Streamlane's). Streamlane's `InsightCount` renders as `<Status tone="attention">9 insights · 4 companies</Status>`.
107
+ - The header's primary action can leave the bar on phones (`hideBelow: "sm"`, which Driftline added for its long
108
+ label); any hidden action is listed in the folded menu, so it stays reachable. Streamlane's "Start free" fits and
109
+ stays.
110
+ - The closing call to action takes `tone="band"` for Driftline's tinted ground under a rule; Streamlane's is plain.
111
+ - The social card's headline wraps at 920 px, Driftline's later width; Streamlane's was 880.
112
+ - The footer's trademark list is set by `Intl.ListFormat`, as Driftline's was; Streamlane's hand-written list reads
113
+ the same.
114
+ - The font cache for the social card is `.next/cache/coral-reef/fonts`, shared, since both set Noto Sans.
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@coralreefventures/site",
3
+ "version": "0.1.0",
4
+ "description": "The shell a Coral Reef public site is built in: header, footer, sections, the social-card builder and the metadata helpers, as server components, parametrized by one SiteConfig.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "engines": {
8
+ "node": ">=24"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/Coral-Reef-Ventures/reef.git",
13
+ "directory": "packages/site"
14
+ },
15
+ "homepage": "https://coralreefventures.com/",
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "keywords": [
20
+ "next",
21
+ "react",
22
+ "server-components",
23
+ "mantine",
24
+ "coral-reef"
25
+ ],
26
+ "files": [
27
+ "src",
28
+ "README.md"
29
+ ],
30
+ "exports": {
31
+ ".": "./src/index.ts",
32
+ "./testing": "./src/testing/index.ts",
33
+ "./base.css": "./src/base.css"
34
+ },
35
+ "peerDependencies": {
36
+ "@mantine/core": "9.6.3",
37
+ "next": "16.3.6",
38
+ "react": "19.3.0",
39
+ "react-dom": "19.3.0"
40
+ },
41
+ "devDependencies": {
42
+ "@types/react": "19.3.0",
43
+ "@types/react-dom": "19.3.0",
44
+ "next": "16.3.6",
45
+ "react": "19.3.0",
46
+ "react-dom": "19.3.0"
47
+ }
48
+ }
@@ -0,0 +1,28 @@
1
+ .frame {
2
+ display: flex;
3
+ align-items: center;
4
+ justify-content: center;
5
+ width: 100%;
6
+ margin: 0;
7
+ padding: 24px;
8
+ box-sizing: border-box;
9
+ border: 1px solid var(--site-border);
10
+ border-radius: var(--mantine-radius-lg);
11
+ background: var(--mantine-color-gray-0);
12
+ }
13
+
14
+ .caption {
15
+ max-width: 44ch;
16
+ font-size: var(--mantine-font-size-xs);
17
+ line-height: 1.55;
18
+ text-align: center;
19
+ color: var(--site-muted);
20
+ }
21
+
22
+ .image {
23
+ display: block;
24
+ width: 100%;
25
+ height: auto;
26
+ border: 1px solid var(--site-border);
27
+ border-radius: var(--mantine-radius-lg);
28
+ }
@@ -0,0 +1,67 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import classes from "./Screenshot.module.css";
5
+
6
+ /**
7
+ * The file a screenshot is captured to, under public/screens: its name (the caption in the copy) as a slug, so
8
+ * "Release v2.3 page: …" is release-v2-3-page-….png. A product's capture script writes the same names.
9
+ */
10
+ export const screenshotFile = (name: string) =>
11
+ `${name
12
+ .normalize("NFKD")
13
+ .toLowerCase()
14
+ .replace(/[^a-z0-9]+/g, "-")
15
+ .replace(/^-|-$/g, "")}.png`;
16
+
17
+ // A PNG's width and height are the first two fields of its IHDR chunk, at bytes 16–23.
18
+ const pngSize = (file: string) => {
19
+ const header = readFileSync(file).subarray(16, 24);
20
+ return { width: header.readUInt32BE(0), height: header.readUInt32BE(4) };
21
+ };
22
+
23
+ type ScreenshotProps = {
24
+ /** The caption from the copy; it names the file and is the image's alt text. */
25
+ name: string;
26
+ /** The placeholder's shape until the image exists, as a CSS aspect ratio. */
27
+ aspect?: string;
28
+ /** Load at once: the screenshot is in the first screen of the page. */
29
+ priority?: boolean;
30
+ /** Where screenshots are kept; the app's public/screens by default. */
31
+ directory?: string;
32
+ };
33
+
34
+ /**
35
+ * A screen from the product. Until public/screens/<name>.png exists (screens are captured by a script, never made
36
+ * by hand), it renders a labeled gray frame with the caption. A server component: it checks for the file at build.
37
+ */
38
+ export const Screenshot = ({
39
+ name,
40
+ aspect = "16 / 10",
41
+ priority = false,
42
+ directory = path.join(process.cwd(), "public", "screens"),
43
+ }: ScreenshotProps) => {
44
+ const file = screenshotFile(name);
45
+ const onDisk = path.join(directory, file);
46
+ if (existsSync(onDisk)) {
47
+ const { width, height } = pngSize(onDisk);
48
+ return (
49
+ // biome-ignore lint/performance/noImgElement: next/image is a client component, and a static export serves the file as it is.
50
+ <img
51
+ className={classes.image}
52
+ src={`/screens/${file}`}
53
+ alt={name}
54
+ width={width}
55
+ height={height}
56
+ loading={priority ? "eager" : "lazy"}
57
+ fetchPriority={priority ? "high" : "auto"}
58
+ decoding="async"
59
+ />
60
+ );
61
+ }
62
+ return (
63
+ <figure className={classes.frame} style={{ aspectRatio: aspect }} data-screenshot={file}>
64
+ <figcaption className={classes.caption}>{name}</figcaption>
65
+ </figure>
66
+ );
67
+ };
@@ -0,0 +1,24 @@
1
+ /*
2
+ * Plain is a neutral tag. Attention is the site's one attention color, which globals.css names as three tokens
3
+ * (the attention hue's palest shade, a mid shade for the edge, its darkest for the text); the primary scale stands
4
+ * in where a site sets none.
5
+ */
6
+ .status {
7
+ display: inline-flex;
8
+ align-items: center;
9
+ padding: 2px 10px;
10
+ border: 1px solid var(--site-border);
11
+ border-radius: 999px;
12
+ background: var(--site-paper);
13
+ color: var(--site-text);
14
+ font-size: var(--mantine-font-size-xs);
15
+ font-weight: 500;
16
+ line-height: 1.6;
17
+ white-space: nowrap;
18
+
19
+ &[data-tone="attention"] {
20
+ border-color: var(--site-attention-edge, var(--mantine-primary-color-3));
21
+ background: var(--site-attention-tint, var(--mantine-primary-color-0));
22
+ color: var(--site-attention-text, var(--mantine-primary-color-9));
23
+ }
24
+ }
package/src/Status.tsx ADDED
@@ -0,0 +1,14 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ import classes from "./Status.module.css";
4
+
5
+ /**
6
+ * When a capability ships, beside the section that describes it ("Ships with the GitHub integration", "Later"). A
7
+ * Coral Reef site describes how a product works and never says a feature is available, so anything not yet shipped
8
+ * carries one of these. `attention` draws it in the site's attention color (--site-attention-*). A server component.
9
+ */
10
+ export const Status = ({ children, tone = "plain" }: { children: ReactNode; tone?: "plain" | "attention" }) => (
11
+ <span className={classes.status} data-tone={tone}>
12
+ {children}
13
+ </span>
14
+ );
package/src/base.css ADDED
@@ -0,0 +1,77 @@
1
+ /*
2
+ * The rules every Coral Reef site shares, outside Mantine's layer so they win over its baseline without raising
3
+ * specificity. Import it in the root layout before the site's own globals.css, which sets the colors:
4
+ *
5
+ * --site-text, --site-muted, --site-border, --site-paper, --site-link-underline,
6
+ * --site-shadow (the folded menu's shadow, a tint of the darkest color), and
7
+ * --site-attention-tint, --site-attention-edge, --site-attention-text (the Status tag's attention tone).
8
+ *
9
+ * The layout tokens below are the shell's own and the same on every site. Media queries use Mantine's breakpoints
10
+ * as literals (sm 48em, md 62em), since custom properties cannot appear in them.
11
+ */
12
+
13
+ :root {
14
+ --site-max-width: 1200px;
15
+ --site-gutter: 24px;
16
+ --site-section-space: 72px;
17
+
18
+ @media (max-width: 48em) {
19
+ --site-gutter: 16px;
20
+ --site-section-space: 48px;
21
+ }
22
+ }
23
+
24
+ /* The text color the theme's components read; the default is pure black. */
25
+ :root[data-mantine-color-scheme="light"] {
26
+ --mantine-color-text: var(--site-text);
27
+ }
28
+
29
+ html {
30
+ -webkit-text-size-adjust: 100%;
31
+ text-size-adjust: 100%;
32
+ }
33
+
34
+ body {
35
+ margin: 0;
36
+ background: var(--mantine-color-white);
37
+ color: var(--site-text);
38
+ overflow-wrap: break-word;
39
+ }
40
+
41
+ /*
42
+ * Inline links (anchors without a class; components style their own) are text-colored with the site's underline
43
+ * color: an accent on white is too light to read as text.
44
+ */
45
+ a:not([class]) {
46
+ color: var(--site-text);
47
+ text-decoration-line: underline;
48
+ text-decoration-color: var(--site-link-underline);
49
+ text-decoration-thickness: 2px;
50
+ text-underline-offset: 3px;
51
+ }
52
+
53
+ a:not([class]):hover {
54
+ text-decoration-color: var(--mantine-primary-color-filled);
55
+ }
56
+
57
+ :focus-visible {
58
+ outline: 2px solid var(--mantine-primary-color-filled);
59
+ outline-offset: 2px;
60
+ }
61
+
62
+ /* The skip link is visible only when focused. */
63
+ .skip-link {
64
+ position: absolute;
65
+ left: 8px;
66
+ top: -48px;
67
+ z-index: 10;
68
+ padding: 8px 12px;
69
+ border-radius: var(--mantine-radius-sm);
70
+ background: var(--mantine-primary-color-filled);
71
+ color: var(--mantine-color-white);
72
+ text-decoration: none;
73
+ }
74
+
75
+ .skip-link:focus {
76
+ top: 8px;
77
+ }
package/src/cards.ts ADDED
@@ -0,0 +1,15 @@
1
+ import type { Card } from "./config";
2
+
3
+ export type { Card };
4
+
5
+ /** Where a page's card is published: "/" is /og/home.png, "/docs/concepts/teams/" is /og/docs/concepts/teams.png. */
6
+ export const cardPath = (pagePath: string) => `/og/${pagePath.replace(/^\/|\/$/g, "") || "home"}.png`;
7
+
8
+ /** The route segments of a card's path, as app/og/[...card] receives them. */
9
+ export const cardSegments = (pagePath: string) =>
10
+ cardPath(pagePath)
11
+ .replace(/^\/og\//, "")
12
+ .split("/");
13
+
14
+ /** The social card is 1200 × 630, the size every network reads. */
15
+ export const socialCardSize = { width: 1200, height: 630 };
package/src/config.ts ADDED
@@ -0,0 +1,61 @@
1
+ import type { Metadata } from "next";
2
+ import type { ReactNode } from "react";
3
+
4
+ /** A header section: `key` is what a page passes as `active` to mark it current. */
5
+ export type NavItem<Key extends string = string> = { key: Key; label: string; href: string };
6
+
7
+ /**
8
+ * A header action beside the navigation. `primary` is the filled button (Start free, Request early access); `link`
9
+ * is a plain link (Sign in). An action with `hideBelow` leaves the bar at that breakpoint (`sm` 36em, `md` 62em) and
10
+ * is listed in the folded menu instead, so it stays reachable on a phone.
11
+ */
12
+ export type HeaderAction = { label: string; href: string; kind: "primary" | "link"; hideBelow?: "sm" | "md" };
13
+
14
+ export type FooterLink = { label: string; href: string };
15
+
16
+ /** A page's social card: its headline and one sentence from its copy. */
17
+ export type Card = { headline: string; sentence?: string };
18
+
19
+ /**
20
+ * How the social card is drawn (1200 × 630): the ground, the headline's color, the sentence's, the domain's, and the
21
+ * product's mark at 72 px, each from the brand. `domain` is the site's host unless another is given.
22
+ */
23
+ export type SocialCardStyle = {
24
+ background: string;
25
+ text: string;
26
+ muted: string;
27
+ accent: string;
28
+ mark: ReactNode;
29
+ domain?: string;
30
+ /** The Google Fonts family the card is set in; Noto Sans, the family's wordmark face, by default. */
31
+ font?: string;
32
+ };
33
+
34
+ export type SiteConfig<Key extends string = string> = {
35
+ /** The product: the title template, the footer's trademark line, the social card's wordmark. */
36
+ name: string;
37
+ /** The public origin, no trailing slash; canonical URLs and the social cards are absolute against it. */
38
+ url: string;
39
+ /** The default description, for the root layout and any page that gives none. */
40
+ description: string;
41
+ /** The header's sections, in order. */
42
+ nav: readonly NavItem<Key>[];
43
+ /** The lockup the header shows at 24 px, the way home. The product's own component, since the mark is the brand. */
44
+ lockup: ReactNode;
45
+ /** The header's actions, in order. */
46
+ actions: readonly HeaderAction[];
47
+ footer: {
48
+ /** The footer's first line: who makes the product and where its source is. */
49
+ maker: ReactNode;
50
+ links: readonly FooterLink[];
51
+ /** The vendors the site names, disclaimed in the footer: "X, Y, and Z are trademarks of their owners". */
52
+ trademarks: readonly string[];
53
+ };
54
+ /** Each page's social card by path (`"/"`, `"/pricing/"`); every page in the sitemap has one. */
55
+ cards: Record<string, Card>;
56
+ socialCard: SocialCardStyle;
57
+ /** The favicon set, as the root layout's metadata lists it. */
58
+ icons: Metadata["icons"];
59
+ /** The browser chrome's color on a phone. */
60
+ themeColor: string;
61
+ };
@@ -0,0 +1,37 @@
1
+ import type { MetadataRoute } from "next";
2
+ import type { ReactNode } from "react";
3
+
4
+ import type { Card, SiteConfig } from "./config";
5
+ import { type PageMetadataInput, pageMetadata, rootMetadata, rootViewport } from "./metadata";
6
+ import { SiteFooter } from "./shell/SiteFooter";
7
+ import { SiteFrame } from "./shell/SiteFrame";
8
+ import { SiteHeader } from "./shell/SiteHeader";
9
+ import { socialCard, socialCardRoute } from "./social-card";
10
+
11
+ /**
12
+ * The shell bound to one site's config, which a product does once in its `lib/site.tsx` and imports from there:
13
+ * `const { SiteFrame, pageMetadata } = createSite({ ... })`. Server components need no provider, so binding is a
14
+ * closure, and `active` is typed to the config's nav keys.
15
+ */
16
+ export const createSite = <const Key extends string>(config: SiteConfig<Key>) => ({
17
+ config,
18
+ SiteFrame: ({ active, children }: { active?: Key | undefined; children: ReactNode }) => (
19
+ <SiteFrame site={config} active={active}>
20
+ {children}
21
+ </SiteFrame>
22
+ ),
23
+ SiteHeader: ({ active }: { active?: Key | undefined }) => <SiteHeader site={config} active={active} />,
24
+ SiteFooter: () => <SiteFooter site={config} />,
25
+ pageMetadata: (page: PageMetadataInput) => pageMetadata(config, page),
26
+ rootMetadata: () => rootMetadata(config),
27
+ rootViewport: () => rootViewport(config),
28
+ /** Everything is public; the sitemap lists every page. */
29
+ robots: (): MetadataRoute.Robots => ({ rules: { userAgent: "*", allow: "/" }, sitemap: `${config.url}/sitemap.xml` }),
30
+ /** Every page with a card, and any more the product lists (docs pages). */
31
+ sitemap: (more: readonly string[] = []): MetadataRoute.Sitemap =>
32
+ [...new Set([...Object.keys(config.cards), ...more])].map((path) => ({ url: `${config.url}${path}` })),
33
+ socialCard: (card: Card) => socialCard(config, card),
34
+ socialCardRoute: (more?: () => Promise<readonly (readonly [string, Card])[]>) => socialCardRoute(config, more),
35
+ });
36
+
37
+ export type Site<Key extends string = string> = ReturnType<typeof createSite<Key>>;
@@ -0,0 +1,5 @@
1
+ // For this repository's own type check and tests. A Next consumer has the same declaration from next/types/global.
2
+ declare module "*.module.css" {
3
+ const classes: Record<string, string>;
4
+ export default classes;
5
+ }
package/src/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ import page from "./page/Page.module.css";
2
+
3
+ export { type Card, cardPath, cardSegments, socialCardSize } from "./cards";
4
+ export type { FooterLink, HeaderAction, NavItem, SiteConfig, SocialCardStyle } from "./config";
5
+ export { createSite, type Site } from "./create-site";
6
+ export { type PageMetadataInput, pageMetadata, rootMetadata, rootViewport } from "./metadata";
7
+ export { CtaBand } from "./page/CtaBand";
8
+ export { Screenshot, screenshotFile } from "./Screenshot";
9
+ export { Status } from "./Status";
10
+ export { Section, Split } from "./shell/Section";
11
+ export { SiteFooter } from "./shell/SiteFooter";
12
+ export { SiteFrame } from "./shell/SiteFrame";
13
+ export { SiteHeader } from "./shell/SiteHeader";
14
+ export { socialCard, socialCardElement, socialCardRoute } from "./social-card";
15
+
16
+ /** The marketing pages' type scale (display, title, lead, h2, h3, subhead, body, small, actions, card, eyebrow). */
17
+ export { page };
@@ -0,0 +1,46 @@
1
+ import type { Metadata, Viewport } from "next";
2
+
3
+ import { type Card, cardPath, socialCardSize } from "./cards";
4
+ import type { SiteConfig } from "./config";
5
+
6
+ export type PageMetadataInput = {
7
+ title: string;
8
+ description: string;
9
+ /** The page's path, with its trailing slash. */
10
+ path: string;
11
+ /** The social card's headline; the pages in the config's `cards` have theirs there. */
12
+ card?: Card | undefined;
13
+ };
14
+
15
+ /**
16
+ * A page's title, description, canonical URL, and social card. URLs are absolute against metadataBase (the site's
17
+ * origin), with the trailing slash every page's URL has. A page's openGraph replaces the root layout's rather than
18
+ * merging with it, so the site-wide fields are repeated here.
19
+ */
20
+ export const pageMetadata = (
21
+ site: SiteConfig,
22
+ { title, description, path, card = site.cards[path] }: PageMetadataInput,
23
+ ): Metadata => {
24
+ const images = [{ url: cardPath(path), ...socialCardSize, type: "image/png", alt: card?.headline ?? title }];
25
+ return {
26
+ title: path === "/" ? { absolute: title } : title,
27
+ description,
28
+ alternates: { canonical: path },
29
+ openGraph: { siteName: site.name, type: "website", locale: "en_US", title, description, url: path, images },
30
+ twitter: { card: "summary_large_image", title, description, images },
31
+ };
32
+ };
33
+
34
+ /** The root layout's metadata: the title template, the default description, the icons, and the site-wide card fields. */
35
+ export const rootMetadata = (site: SiteConfig): Metadata => ({
36
+ metadataBase: new URL(site.url),
37
+ title: { default: site.name, template: `%s · ${site.name}` },
38
+ description: site.description,
39
+ applicationName: site.name,
40
+ openGraph: { siteName: site.name, type: "website", locale: "en_US" },
41
+ twitter: { card: "summary_large_image" },
42
+ icons: site.icons,
43
+ });
44
+
45
+ /** The root layout's viewport: the brand's color in the browser chrome, and a light-only site. */
46
+ export const rootViewport = (site: SiteConfig): Viewport => ({ themeColor: site.themeColor, colorScheme: "light" });
@@ -0,0 +1,43 @@
1
+ .band {
2
+ padding-block: 56px;
3
+
4
+ &[data-tone="band"] {
5
+ background: var(--site-paper);
6
+ border-top: 1px solid var(--site-border);
7
+ }
8
+ }
9
+
10
+ .inner {
11
+ display: flex;
12
+ flex-wrap: wrap;
13
+ align-items: center;
14
+ justify-content: space-between;
15
+ gap: 24px 48px;
16
+ max-width: var(--site-max-width);
17
+ margin-inline: auto;
18
+ padding-inline: var(--site-gutter);
19
+ box-sizing: border-box;
20
+ }
21
+
22
+ .copy {
23
+ flex: 1 1 28rem;
24
+ }
25
+
26
+ .title {
27
+ margin: 0;
28
+ font-size: 24px;
29
+ line-height: 1.3;
30
+ }
31
+
32
+ .text {
33
+ margin: 6px 0 0;
34
+ font-size: var(--mantine-font-size-md);
35
+ line-height: 1.55;
36
+ color: var(--site-muted);
37
+ }
38
+
39
+ .actions {
40
+ display: flex;
41
+ flex-wrap: wrap;
42
+ gap: 12px;
43
+ }
@@ -0,0 +1,31 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ import classes from "./CtaBand.module.css";
4
+
5
+ /**
6
+ * The closing call to action on Home and Product: a heading and a sentence, with the buttons to the right. `band`
7
+ * sets it on the tinted ground under a rule, as Driftline's pages do.
8
+ */
9
+ export const CtaBand = ({
10
+ title,
11
+ text,
12
+ actions,
13
+ tone = "plain",
14
+ }: {
15
+ title: string;
16
+ text: string;
17
+ actions: ReactNode;
18
+ tone?: "plain" | "band";
19
+ }) => (
20
+ <section className={classes.band} data-tone={tone} aria-labelledby="cta-title">
21
+ <div className={classes.inner}>
22
+ <div className={classes.copy}>
23
+ <h2 id="cta-title" className={classes.title}>
24
+ {title}
25
+ </h2>
26
+ <p className={classes.text}>{text}</p>
27
+ </div>
28
+ <div className={classes.actions}>{actions}</div>
29
+ </div>
30
+ </section>
31
+ );