@spunto/design-system 0.15.1 → 0.16.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 +74 -0
- package/package.json +5 -1
- package/src/components/marketing/code-block.tsx +89 -0
- package/src/components/marketing/copy-command.tsx +139 -0
- package/src/components/marketing/index.ts +55 -0
- package/src/components/marketing/motion.ts +92 -0
- package/src/components/marketing/night-band.tsx +75 -0
- package/src/components/marketing/product-header.tsx +115 -0
- package/src/components/marketing/reveal.tsx +71 -0
- package/src/components/marketing/section.tsx +161 -0
- package/styles.css +67 -0
package/README.md
CHANGED
|
@@ -46,6 +46,9 @@ const nextConfig = { transpilePackages: ["@spunto/design-system"] }
|
|
|
46
46
|
→ Tailwind color/radius mapping, a minimal base layer, and the `dot-grid` /
|
|
47
47
|
`grid-lines` background utilities. Brand color is **flame** (`--primary` ≈ `#ea5400`);
|
|
48
48
|
radius base is `0.25rem`; `Build`/`Ship`/`Run` are semantic (`bg-build`, `text-run`…).
|
|
49
|
+
Plus the two **unthemed** marketing surfaces — `--night` (`bg-night`) and
|
|
50
|
+
`--code-surface` (`bg-code-surface`) — and the `night-grid` / `night-glow-a`
|
|
51
|
+
/ `night-glow-b` / `night-scroll` utilities they come with.
|
|
49
52
|
- **`cn`** — `clsx` + `tailwind-merge`.
|
|
50
53
|
- **`./colors`** — `cssVar`, `chartColors`, `chartRamp`, `segColors` for JS/chart contexts.
|
|
51
54
|
- **Primitives** — form + layout building blocks styled on `@base-ui/react`:
|
|
@@ -112,11 +115,16 @@ import { Button, Card } from "@spunto/design-system"
|
|
|
112
115
|
import { WorkerCard } from "@spunto/design-system/workers" // domain
|
|
113
116
|
import { ImageCard, FeatureCard, ExtensionCard } from "@spunto/design-system/devcontainer" // domain
|
|
114
117
|
import { ProjectForm, ProjectPanel } from "@spunto/design-system/projects" // domain
|
|
118
|
+
import { Section, ProductHeader } from "@spunto/design-system/marketing" // not a domain — see below
|
|
115
119
|
```
|
|
116
120
|
|
|
117
121
|
One entry **per domain**, not per component: a project's form and a project's
|
|
118
122
|
panel are the same concept seen twice, so they share `/projects`.
|
|
119
123
|
|
|
124
|
+
`/marketing` is the exception that proves the rule: it knows no Spunto concept
|
|
125
|
+
(that's exactly why the product registry stayed in the site), but it carries
|
|
126
|
+
opinions a dashboard doesn't want, so it gets an entry too.
|
|
127
|
+
|
|
120
128
|
### `@spunto/design-system/workers`
|
|
121
129
|
|
|
122
130
|
- **`WorkerCard`** — a Spunto worker as a card: state, author, node, setup
|
|
@@ -264,6 +272,72 @@ And the read-only twin of that form:
|
|
|
264
272
|
- **Container queries** here too: the panel is 288 px in a sidebar on a 27" screen
|
|
265
273
|
and full width on a phone — and the desktop case is the narrow one.
|
|
266
274
|
|
|
275
|
+
## Marketing — `/marketing`
|
|
276
|
+
|
|
277
|
+
```tsx
|
|
278
|
+
import { Section, H2, Kicker, SpecList, Disclosure, CodeBlock, Figure,
|
|
279
|
+
CopyCommand, NightBand, NightChip, ProductHeader, Reveal,
|
|
280
|
+
usePrefersReducedMotion, useLoopClock } from "@spunto/design-system/marketing"
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
The vocabulary of a **marketing page** — a numbered chapter, a display headline,
|
|
284
|
+
a mono kicker, key/value rows, a disclosure row, a copyable command, a night
|
|
285
|
+
band. Its own entry rather than the root, for two reasons: an app that draws a
|
|
286
|
+
dashboard has no use for it, and these primitives carry opinions the root
|
|
287
|
+
deliberately doesn't (a display face, a fixed dark surface).
|
|
288
|
+
|
|
289
|
+
- **`Section`** — the chapter: hairline, number in the accent, mono title,
|
|
290
|
+
margin `note`, then the content. Repeating one opening down a page is what
|
|
291
|
+
makes it read as a document rather than a stack of blocks.
|
|
292
|
+
- **`H2`** / **`Kicker`** — the display headline (Syne, capped at 24ch) and the
|
|
293
|
+
mono line above it. **One `Kicker`, not two**: the site had `Kicker` and
|
|
294
|
+
`Eyebrow` doing the same job under two names and two letter-spacings; the wider
|
|
295
|
+
one is now a `className`.
|
|
296
|
+
- **`SpecList`** — key/value rows, the default way to list facts without bullets.
|
|
297
|
+
- **`Disclosure`** — the row you open. `<details name>` gives the exclusive
|
|
298
|
+
accordion natively: no state, no hydration surface, and it works before the JS
|
|
299
|
+
lands.
|
|
300
|
+
- **`CodeBlock`** / **`Figure`** — a snippet on the `--code-surface` token
|
|
301
|
+
(deliberately unhighlighted: a highlighter is a dependency and ~30 kB for a
|
|
302
|
+
six-line snippet), and a framed, captioned screenshot whose **image element is
|
|
303
|
+
a slot** (`render.image`) — same rule as links, the package never imports
|
|
304
|
+
`next/image`.
|
|
305
|
+
- **`CopyCommand`** — the call to action when it's a command. **One component,
|
|
306
|
+
two registers** (`tone="day" | "night"`), where the site had `CopyCommand` and
|
|
307
|
+
`NightCommand`: same behaviour, two files, two places to fix a bug. What lands
|
|
308
|
+
in the clipboard is the joined-up one-liner, not the wrapped version.
|
|
309
|
+
- **`NightBand`** / **`NightChip`** — the dark band a page opens on, lit by its
|
|
310
|
+
`glow` accent. `--night` is *not themed*: it's the dark moment of the page, so
|
|
311
|
+
it stays deeper than the dark theme's own background and identical in both.
|
|
312
|
+
The drifting light sources are CSS animations (stopped by
|
|
313
|
+
`prefers-reduced-motion`), never a rAF loop.
|
|
314
|
+
- **`ProductHeader`** — mark, name, kind, headline, promise, the one command, the
|
|
315
|
+
chips. **Generalised on the way in**: the site's version took a `Product` out
|
|
316
|
+
of its own registry, this one knows about no product at all — everything
|
|
317
|
+
product-specific goes through `actions`.
|
|
318
|
+
- **`Reveal`** + **`usePrefersReducedMotion`** / **`useLoopClock`** — scroll
|
|
319
|
+
reveal as an `IntersectionObserver` and a CSS transition, and the "how far into
|
|
320
|
+
the loop are we" clock that pauses off screen and commits at ~14 Hz.
|
|
321
|
+
|
|
322
|
+
Two rules the package enforces here:
|
|
323
|
+
|
|
324
|
+
- **No framework imports.** No `next/link`, no `next/image`. Navigation is the
|
|
325
|
+
caller's (`actions` slots), and `Figure` takes an image `render` slot — the
|
|
326
|
+
same motif as `CommandPaletteLinkRender` and `WorkerCard`'s `render.link`.
|
|
327
|
+
- **No motion library.** `framer-motion` as a hard dependency would weigh on
|
|
328
|
+
every consumer, including the ones that never draw a landing page. `Reveal` is
|
|
329
|
+
~20 lines of observer + transition instead.
|
|
330
|
+
|
|
331
|
+
`Section`, `H2`, `Kicker`, `SpecList`, `Disclosure`, `CodeBlock`, `Figure`,
|
|
332
|
+
`NightBand`, `NightChip` and `ProductHeader` carry **no `"use client"`** — they
|
|
333
|
+
render from a React Server Component. Only `CopyCommand`, `Reveal` and the hooks
|
|
334
|
+
are client files. (Same split, same care, as `buttonVariants`/`alertVariants`.)
|
|
335
|
+
|
|
336
|
+
**What stays in the site, on purpose:** the product registry and anything reading
|
|
337
|
+
it, the navs and footers (they hard-code URLs and a session), the product logos
|
|
338
|
+
and OG cards. The rule: *if it needs to know a product, a URL or a session, it's
|
|
339
|
+
the site; if it only lays out or types, it's the system.*
|
|
340
|
+
|
|
267
341
|
## Toasts — `SpuntoProvider` + `toast()`
|
|
268
342
|
|
|
269
343
|
`SpuntoProvider` is the design system's client umbrella provider. Mount it once
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spunto/design-system",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Spunto's shared design system — warm/flame tokens, color constants, and UI primitives.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -45,6 +45,10 @@
|
|
|
45
45
|
"types": "./src/components/projects/index.ts",
|
|
46
46
|
"import": "./src/components/projects/index.ts"
|
|
47
47
|
},
|
|
48
|
+
"./marketing": {
|
|
49
|
+
"types": "./src/components/marketing/index.ts",
|
|
50
|
+
"import": "./src/components/marketing/index.ts"
|
|
51
|
+
},
|
|
48
52
|
"./fonts": {
|
|
49
53
|
"types": "./src/components/fonts.tsx",
|
|
50
54
|
"import": "./src/components/fonts.tsx"
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Snippets and screenshots — the two ways a marketing page shows the product
|
|
2
|
+
// rather than describing it. Both static, both server-renderable.
|
|
3
|
+
|
|
4
|
+
import type { ReactElement, ReactNode } from "react"
|
|
5
|
+
|
|
6
|
+
import { cn } from "../../utils"
|
|
7
|
+
|
|
8
|
+
/* ─── CodeBlock ────────────────────────────────────────────────────────────
|
|
9
|
+
A real snippet (a compose file, a script, an import), on the same warm dark
|
|
10
|
+
surface as every other terminal-ish panel — the `--code-surface` token
|
|
11
|
+
(`bg-code-surface`), so the value lives in `styles.css` and not as a magic
|
|
12
|
+
`oklch(…)` string recopied into each component.
|
|
13
|
+
|
|
14
|
+
Deliberately unhighlighted: a syntax highlighter is another dependency and
|
|
15
|
+
another 30 kB on a marketing page, and these snippets are six lines long. A
|
|
16
|
+
consumer that wants highlighting renders its own `<pre>` inside the frame. */
|
|
17
|
+
|
|
18
|
+
export interface CodeBlockProps {
|
|
19
|
+
code: string
|
|
20
|
+
/** Optional filename bar above the snippet. */
|
|
21
|
+
filename?: ReactNode
|
|
22
|
+
className?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function CodeBlock({ code, filename, className }: CodeBlockProps) {
|
|
26
|
+
return (
|
|
27
|
+
<div translate="no" className={cn("overflow-hidden rounded-lg border border-border", className)}>
|
|
28
|
+
{filename && (
|
|
29
|
+
<div className="border-b border-border bg-card px-4 py-2 font-mono text-[10.5px] uppercase tracking-[0.14em] text-muted-foreground">
|
|
30
|
+
{filename}
|
|
31
|
+
</div>
|
|
32
|
+
)}
|
|
33
|
+
<pre className="night-scroll overflow-x-auto bg-code-surface px-4 py-4 font-mono text-[11.5px] leading-[1.8] text-stone-200 md:text-[12.5px]">
|
|
34
|
+
<code>{code}</code>
|
|
35
|
+
</pre>
|
|
36
|
+
</div>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* ─── Figure — a real screenshot, framed and captioned ─────────────────────
|
|
41
|
+
Marketing illustrations are product screenshots rather than drawings, so they
|
|
42
|
+
get a browser-ish frame and a caption saying what you're looking at.
|
|
43
|
+
|
|
44
|
+
The image element is a **slot**, for the same reason links are: the package
|
|
45
|
+
never imports `next/image` (or any framework primitive). Default is a plain
|
|
46
|
+
`<img>`; a Next app passes `render={{ image: (p) => <Image {...p} /> }}` and
|
|
47
|
+
gets its optimizer back. */
|
|
48
|
+
|
|
49
|
+
export interface FigureImageProps {
|
|
50
|
+
src: string
|
|
51
|
+
alt: string
|
|
52
|
+
width: number
|
|
53
|
+
height: number
|
|
54
|
+
className?: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type FigureImageRender = (props: FigureImageProps) => ReactElement
|
|
58
|
+
|
|
59
|
+
export interface FigureProps {
|
|
60
|
+
src: string
|
|
61
|
+
alt: string
|
|
62
|
+
caption?: ReactNode
|
|
63
|
+
width: number
|
|
64
|
+
height: number
|
|
65
|
+
render?: { image?: FigureImageRender }
|
|
66
|
+
className?: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function Figure({ src, alt, caption, width, height, render, className }: FigureProps) {
|
|
70
|
+
const image = render?.image ?? ((props: FigureImageProps) => <img {...props} alt={props.alt} />)
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<figure className={className}>
|
|
74
|
+
<div className="overflow-hidden rounded-lg border border-border bg-card">
|
|
75
|
+
<div className="flex items-center gap-1.5 border-b border-border px-3 py-2" aria-hidden>
|
|
76
|
+
<span className="h-2.5 w-2.5 rounded-full bg-border" />
|
|
77
|
+
<span className="h-2.5 w-2.5 rounded-full bg-border" />
|
|
78
|
+
<span className="h-2.5 w-2.5 rounded-full bg-border" />
|
|
79
|
+
</div>
|
|
80
|
+
{image({ src, alt, width, height, className: "w-full" })}
|
|
81
|
+
</div>
|
|
82
|
+
{caption && (
|
|
83
|
+
<figcaption className="mt-2.5 font-mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground/60">
|
|
84
|
+
{caption}
|
|
85
|
+
</figcaption>
|
|
86
|
+
)}
|
|
87
|
+
</figure>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState, type ReactNode } from "react"
|
|
4
|
+
import { CheckIcon, CopyIcon } from "lucide-react"
|
|
5
|
+
|
|
6
|
+
import { cn } from "../../utils"
|
|
7
|
+
|
|
8
|
+
/* ─── CopyCommand — the call to action, when it's a command ────────────────
|
|
9
|
+
The primary action on an infrastructure landing is usually a command rather
|
|
10
|
+
than a signup form, so it gets the care a button would: the copy affordance
|
|
11
|
+
is always visible (there is no hover on a phone), and what lands in the
|
|
12
|
+
clipboard is the joined-up one-liner, not the pretty wrapped version.
|
|
13
|
+
|
|
14
|
+
**One component, two registers.** The site had this twice — `CopyCommand` on
|
|
15
|
+
the light document and `NightCommand` on the dark band, same behaviour, two
|
|
16
|
+
files, two places to fix a bug. Here it's one component and a `tone`:
|
|
17
|
+
|
|
18
|
+
- `"day"` — bordered card on the light document, dark `<pre>` inside.
|
|
19
|
+
- `"night"` — translucent black on a `NightBand`, no opaque surface of its
|
|
20
|
+
own so the band's glow shows through.
|
|
21
|
+
|
|
22
|
+
`accent` colours the `$` prompt (and, in the night register, is the only warm
|
|
23
|
+
thing in the block) — a page passes its own product accent. */
|
|
24
|
+
|
|
25
|
+
export type CommandTone = "day" | "night"
|
|
26
|
+
|
|
27
|
+
export interface CopyCommandProps {
|
|
28
|
+
command: string
|
|
29
|
+
/** Mono label in the bar — "your machine", "on each node you connect"… */
|
|
30
|
+
caption?: ReactNode
|
|
31
|
+
/** Small print under the block. */
|
|
32
|
+
note?: ReactNode
|
|
33
|
+
tone?: CommandTone
|
|
34
|
+
/** Any CSS colour, used for the `$` prompt. */
|
|
35
|
+
accent?: string
|
|
36
|
+
className?: string
|
|
37
|
+
/** Label of the copy button, for screen readers. */
|
|
38
|
+
copyLabel?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function CopyCommand({
|
|
42
|
+
command,
|
|
43
|
+
caption,
|
|
44
|
+
note,
|
|
45
|
+
tone = "day",
|
|
46
|
+
accent,
|
|
47
|
+
className,
|
|
48
|
+
copyLabel = "Copy the command",
|
|
49
|
+
}: CopyCommandProps) {
|
|
50
|
+
const [copied, setCopied] = useState(false)
|
|
51
|
+
// A visitor who copies then navigates away would otherwise leave a 2s timer
|
|
52
|
+
// behind, firing setState on an unmounted component.
|
|
53
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
54
|
+
useEffect(() => () => void (timer.current && clearTimeout(timer.current)), [])
|
|
55
|
+
|
|
56
|
+
function copy() {
|
|
57
|
+
// The multi-line form is for reading; what gets pasted is the one-liner.
|
|
58
|
+
const text = command.replace(/\\\n\s*/g, "")
|
|
59
|
+
navigator.clipboard
|
|
60
|
+
?.writeText(text)
|
|
61
|
+
.then(() => {
|
|
62
|
+
setCopied(true)
|
|
63
|
+
if (timer.current) clearTimeout(timer.current)
|
|
64
|
+
timer.current = setTimeout(() => setCopied(false), 2000)
|
|
65
|
+
})
|
|
66
|
+
.catch(() => {
|
|
67
|
+
/* Denied permission / insecure context: the command is still selectable. */
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const night = tone === "night"
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div className={className}>
|
|
75
|
+
<div
|
|
76
|
+
translate="no"
|
|
77
|
+
className={cn(
|
|
78
|
+
"overflow-hidden rounded-lg border",
|
|
79
|
+
night ? "border-white/15 bg-black/30 backdrop-blur-sm" : "border-border"
|
|
80
|
+
)}
|
|
81
|
+
>
|
|
82
|
+
<div
|
|
83
|
+
className={cn(
|
|
84
|
+
"flex items-center justify-between gap-3 border-b px-3 py-2 font-mono text-[10.5px] uppercase tracking-[0.14em] md:px-4",
|
|
85
|
+
night ? "border-white/10 text-stone-400" : "border-border bg-card text-muted-foreground"
|
|
86
|
+
)}
|
|
87
|
+
>
|
|
88
|
+
<span className="truncate">{caption ?? "your machine"}</span>
|
|
89
|
+
<button
|
|
90
|
+
type="button"
|
|
91
|
+
onClick={copy}
|
|
92
|
+
aria-label={copyLabel}
|
|
93
|
+
className={cn(
|
|
94
|
+
"inline-flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1 text-[10px] uppercase tracking-[0.14em] transition-colors",
|
|
95
|
+
night
|
|
96
|
+
? "border-white/15 hover:border-white/35 hover:text-white"
|
|
97
|
+
: "border-border hover:border-primary/40 hover:text-foreground"
|
|
98
|
+
)}
|
|
99
|
+
>
|
|
100
|
+
{copied ? (
|
|
101
|
+
<>
|
|
102
|
+
<CheckIcon className={cn("h-3 w-3", night ? "text-emerald-400" : "text-emerald-500")} />
|
|
103
|
+
copied
|
|
104
|
+
</>
|
|
105
|
+
) : (
|
|
106
|
+
<>
|
|
107
|
+
<CopyIcon className="h-3 w-3" />
|
|
108
|
+
copy
|
|
109
|
+
</>
|
|
110
|
+
)}
|
|
111
|
+
</button>
|
|
112
|
+
</div>
|
|
113
|
+
<pre
|
|
114
|
+
className={cn(
|
|
115
|
+
"night-scroll overflow-x-auto px-3 py-3.5 font-mono text-[11px] leading-[1.75] text-stone-200 md:px-4 md:text-[12px]",
|
|
116
|
+
// On the band the block stays translucent; on the document it needs
|
|
117
|
+
// its own dark surface, the same one CodeBlock uses.
|
|
118
|
+
!night && "bg-code-surface"
|
|
119
|
+
)}
|
|
120
|
+
>
|
|
121
|
+
<code>
|
|
122
|
+
<span
|
|
123
|
+
className={cn("select-none", !accent && !night && "text-primary", !accent && night && "text-flame")}
|
|
124
|
+
style={accent ? { color: accent } : undefined}
|
|
125
|
+
>
|
|
126
|
+
${" "}
|
|
127
|
+
</span>
|
|
128
|
+
{command}
|
|
129
|
+
</code>
|
|
130
|
+
</pre>
|
|
131
|
+
</div>
|
|
132
|
+
{note && (
|
|
133
|
+
<p className={cn("mt-2.5 text-[12.5px] leading-relaxed", night ? "text-stone-400" : "text-muted-foreground")}>
|
|
134
|
+
{note}
|
|
135
|
+
</p>
|
|
136
|
+
)}
|
|
137
|
+
</div>
|
|
138
|
+
)
|
|
139
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// @spunto/design-system/marketing — the vocabulary of a marketing page.
|
|
2
|
+
//
|
|
3
|
+
// Fourth sub-export, after `/workers`, `/devcontainer` and `/projects`, and the
|
|
4
|
+
// first one that isn't a Spunto domain: these are layout and typography
|
|
5
|
+
// primitives — a numbered chapter, a display headline, a mono kicker, key/value
|
|
6
|
+
// rows, a disclosure row, a copyable command, a night band. They live behind
|
|
7
|
+
// their own entry rather than in the root because an app that draws a dashboard
|
|
8
|
+
// has no use for them, and because they carry their own opinions (a display
|
|
9
|
+
// face, a fixed dark surface) that the root primitives deliberately don't.
|
|
10
|
+
//
|
|
11
|
+
// **The rule for what belongs here**, applied on the way in and worth keeping:
|
|
12
|
+
// if a component needs to know a product, a URL or a session, it stays in the
|
|
13
|
+
// site. If it only lays out or types, it comes here. That is why the site's
|
|
14
|
+
// product registry, its navs and footers, its product logos and its OG cards
|
|
15
|
+
// did *not* travel — they are the site, not the system.
|
|
16
|
+
//
|
|
17
|
+
// Two constraints the package enforces that the site's own kits didn't have to:
|
|
18
|
+
//
|
|
19
|
+
// - **No framework imports.** No `next/link`, no `next/image`. Navigation is
|
|
20
|
+
// the caller's (`actions` slots, plain children) and `Figure` takes an image
|
|
21
|
+
// `render` slot — the same motif as `CommandPaletteLinkRender` and
|
|
22
|
+
// `WorkerCard`'s `render.link`.
|
|
23
|
+
// - **No motion library.** `Reveal` is an IntersectionObserver and a CSS
|
|
24
|
+
// transition; `useLoopClock` is one rAF. A design system doesn't put
|
|
25
|
+
// `framer-motion` in the bundle of consumers who never draw a landing page.
|
|
26
|
+
//
|
|
27
|
+
// Server components: everything static (`Section`, `H2`, `Kicker`, `SpecList`,
|
|
28
|
+
// `Disclosure`, `CodeBlock`, `Figure`, `NightBand`, `NightChip`,
|
|
29
|
+
// `ProductHeader`) is free of `"use client"` and renders on the server. Only
|
|
30
|
+
// `CopyCommand`, `Reveal` and the hooks are client files.
|
|
31
|
+
|
|
32
|
+
export { Section, H2, Kicker, SpecList, Disclosure } from "./section"
|
|
33
|
+
export type { SectionProps, H2Props, KickerProps, SpecListProps, DisclosureProps } from "./section"
|
|
34
|
+
|
|
35
|
+
export { CodeBlock, Figure } from "./code-block"
|
|
36
|
+
export type { CodeBlockProps, FigureProps, FigureImageProps, FigureImageRender } from "./code-block"
|
|
37
|
+
|
|
38
|
+
export { CopyCommand } from "./copy-command"
|
|
39
|
+
export type { CopyCommandProps, CommandTone } from "./copy-command"
|
|
40
|
+
|
|
41
|
+
export { NightBand, NightChip } from "./night-band"
|
|
42
|
+
export type { NightBandProps, NightChipProps } from "./night-band"
|
|
43
|
+
|
|
44
|
+
export { ProductHeader } from "./product-header"
|
|
45
|
+
export type { ProductHeaderProps } from "./product-header"
|
|
46
|
+
|
|
47
|
+
export { Reveal } from "./reveal"
|
|
48
|
+
export type { RevealProps } from "./reveal"
|
|
49
|
+
|
|
50
|
+
export { usePrefersReducedMotion, useLoopClock } from "./motion"
|
|
51
|
+
export type { LoopClock } from "./motion"
|
|
52
|
+
|
|
53
|
+
// Re-exported here so a consumer of the marketing surface doesn't have to import
|
|
54
|
+
// the root entry just to compose class names.
|
|
55
|
+
export { cn } from "../../utils"
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState, useSyncExternalStore } from "react"
|
|
4
|
+
|
|
5
|
+
/* ─── prefers-reduced-motion, as a value ───────────────────────────────────
|
|
6
|
+
`useSyncExternalStore` rather than an effect + state: the subscription *is*
|
|
7
|
+
the media query, so there is no first-render frame with the wrong answer and
|
|
8
|
+
nothing to keep in sync by hand. The server snapshot is `false` (assume
|
|
9
|
+
motion, then correct on hydration) — the alternative renders the still frame
|
|
10
|
+
to everyone. */
|
|
11
|
+
|
|
12
|
+
const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)"
|
|
13
|
+
|
|
14
|
+
function subscribeReducedMotion(onChange: () => void) {
|
|
15
|
+
const mq = window.matchMedia(REDUCED_MOTION_QUERY)
|
|
16
|
+
mq.addEventListener("change", onChange)
|
|
17
|
+
return () => mq.removeEventListener("change", onChange)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function usePrefersReducedMotion(): boolean {
|
|
21
|
+
return useSyncExternalStore(
|
|
22
|
+
subscribeReducedMotion,
|
|
23
|
+
() => window.matchMedia(REDUCED_MOTION_QUERY).matches,
|
|
24
|
+
() => false
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/* ─── useLoopClock — "how far into the loop are we" ────────────────────────
|
|
29
|
+
The animated artifacts of a landing page (a terminal typing itself out, a
|
|
30
|
+
build filling up) are usually pure functions of one number. Modelling them
|
|
31
|
+
that way keeps each to a single rAF, makes the reduced-motion path a matter
|
|
32
|
+
of picking a frame, and makes them trivially testable.
|
|
33
|
+
|
|
34
|
+
Two things this does that a naive rAF loop doesn't:
|
|
35
|
+
|
|
36
|
+
- it **stops when the element is off screen** (IntersectionObserver on the
|
|
37
|
+
returned ref), so a page with three animated panels runs one loop, not
|
|
38
|
+
three;
|
|
39
|
+
- it **commits at ~14 Hz, not 60** (`resolutionMs`) — a marketing page has
|
|
40
|
+
no business re-rendering a React tree on every frame.
|
|
41
|
+
|
|
42
|
+
Attach `ref` to the element that has to be visible for the clock to run, and
|
|
43
|
+
drive everything else from `t` (milliseconds into the loop). */
|
|
44
|
+
|
|
45
|
+
export interface LoopClock<T extends HTMLElement> {
|
|
46
|
+
/** Milliseconds elapsed in the current loop, in `[0, loop)`. */
|
|
47
|
+
t: number
|
|
48
|
+
/** Attach to the animated element — the clock pauses while it's off screen. */
|
|
49
|
+
ref: React.RefObject<T | null>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function useLoopClock<T extends HTMLElement = HTMLDivElement>(
|
|
53
|
+
/** Loop length, in milliseconds. */
|
|
54
|
+
loop: number,
|
|
55
|
+
/** `false` freezes the clock at 0 — pass `!prefersReducedMotion`. */
|
|
56
|
+
enabled: boolean,
|
|
57
|
+
/** Minimum gap between two committed values. */
|
|
58
|
+
resolutionMs = 70,
|
|
59
|
+
/** Changing it restarts the loop from zero. */
|
|
60
|
+
resetKey: unknown = null
|
|
61
|
+
): LoopClock<T> {
|
|
62
|
+
const [t, setT] = useState(0)
|
|
63
|
+
const ref = useRef<T>(null)
|
|
64
|
+
const [visible, setVisible] = useState(true)
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
const el = ref.current
|
|
68
|
+
if (!el) return
|
|
69
|
+
const io = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting), { rootMargin: "120px" })
|
|
70
|
+
io.observe(el)
|
|
71
|
+
return () => io.disconnect()
|
|
72
|
+
}, [])
|
|
73
|
+
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (!enabled || !visible) return
|
|
76
|
+
let raf = 0
|
|
77
|
+
let committed = -1
|
|
78
|
+
const start = performance.now()
|
|
79
|
+
const tick = (now: number) => {
|
|
80
|
+
const v = (now - start) % loop
|
|
81
|
+
if (v < committed || v - committed >= resolutionMs) {
|
|
82
|
+
committed = v
|
|
83
|
+
setT(v)
|
|
84
|
+
}
|
|
85
|
+
raf = requestAnimationFrame(tick)
|
|
86
|
+
}
|
|
87
|
+
raf = requestAnimationFrame(tick)
|
|
88
|
+
return () => cancelAnimationFrame(raf)
|
|
89
|
+
}, [enabled, visible, loop, resolutionMs, resetKey])
|
|
90
|
+
|
|
91
|
+
return { t, ref }
|
|
92
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// The night register — a fixed dark band inside an otherwise light document.
|
|
2
|
+
//
|
|
3
|
+
// Static markup only (no state, no effect), so it stays server-renderable: the
|
|
4
|
+
// drifting light source is a CSS animation carried by `styles.css`, not a rAF
|
|
5
|
+
// loop, because this is usually the most-viewed element of a site and it has no
|
|
6
|
+
// business holding one open.
|
|
7
|
+
|
|
8
|
+
import type { ReactNode } from "react"
|
|
9
|
+
|
|
10
|
+
import { cn } from "../../utils"
|
|
11
|
+
|
|
12
|
+
/* ─── NightBand ────────────────────────────────────────────────────────────
|
|
13
|
+
A page that is cream on cream from top to bottom is calm at a glance and
|
|
14
|
+
flat over its full height. The band is the contrast: near-black, with the
|
|
15
|
+
accent as a light source behind it, dropping back into the document
|
|
16
|
+
underneath on a hairline.
|
|
17
|
+
|
|
18
|
+
The surface is the `--night` token (`bg-night`), deliberately deeper than the
|
|
19
|
+
dark theme's own background so the band still reads as a band when the whole
|
|
20
|
+
site is already dark — and identical in both themes, since it *is* the dark
|
|
21
|
+
moment of the page rather than a themed surface. */
|
|
22
|
+
|
|
23
|
+
export interface NightBandProps {
|
|
24
|
+
children: ReactNode
|
|
25
|
+
/** The light source. A page passes its own accent — any CSS colour. */
|
|
26
|
+
glow?: string
|
|
27
|
+
className?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function NightBand({ children, glow = "var(--flame)", className }: NightBandProps) {
|
|
31
|
+
return (
|
|
32
|
+
<div className={cn("relative isolate overflow-hidden bg-night text-stone-100", className)}>
|
|
33
|
+
<div aria-hidden className="night-grid pointer-events-none absolute inset-0" />
|
|
34
|
+
{/* `glow` transitions, so a page that lets the visitor pick a product in
|
|
35
|
+
the hero changes temperature with the selection. */}
|
|
36
|
+
<div
|
|
37
|
+
aria-hidden
|
|
38
|
+
className="night-glow-a pointer-events-none absolute -left-[10%] -top-[45%] h-[85%] w-[70%] opacity-[0.5] transition-[background] duration-700"
|
|
39
|
+
style={{ background: `radial-gradient(closest-side, ${glow}, transparent)` }}
|
|
40
|
+
/>
|
|
41
|
+
<div
|
|
42
|
+
aria-hidden
|
|
43
|
+
className="night-glow-b pointer-events-none absolute -bottom-[55%] right-[-15%] h-[90%] w-[60%] opacity-[0.22] transition-[background] duration-700"
|
|
44
|
+
style={{ background: `radial-gradient(closest-side, ${glow}, transparent)` }}
|
|
45
|
+
/>
|
|
46
|
+
{/* Hairline at the seam, so the band ends on a line like everything else. */}
|
|
47
|
+
<div aria-hidden className="pointer-events-none absolute inset-x-0 bottom-0 h-px bg-white/10" />
|
|
48
|
+
<div className="relative">{children}</div>
|
|
49
|
+
</div>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* ─── NightChip — a small fact on a night surface ─────────────────────────── */
|
|
54
|
+
|
|
55
|
+
export interface NightChipProps {
|
|
56
|
+
children: ReactNode
|
|
57
|
+
/** Any CSS colour: tints the border and lights the leading dot. */
|
|
58
|
+
accent?: string
|
|
59
|
+
className?: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function NightChip({ children, accent, className }: NightChipProps) {
|
|
63
|
+
return (
|
|
64
|
+
<span
|
|
65
|
+
className={cn(
|
|
66
|
+
"inline-flex items-center gap-1.5 rounded-full border border-white/15 px-2 py-1 font-mono text-[9px] uppercase tracking-[0.1em] text-stone-300 sm:px-2.5 sm:text-[10px] sm:tracking-[0.14em]",
|
|
67
|
+
className
|
|
68
|
+
)}
|
|
69
|
+
style={accent ? { borderColor: `color-mix(in oklch, ${accent} 45%, transparent)` } : undefined}
|
|
70
|
+
>
|
|
71
|
+
{accent && <span className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: accent }} />}
|
|
72
|
+
{children}
|
|
73
|
+
</span>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// The opening of a product page. Static shell (server-renderable) around the
|
|
2
|
+
// one interactive part, `CopyCommand`.
|
|
3
|
+
|
|
4
|
+
import type { ReactNode } from "react"
|
|
5
|
+
|
|
6
|
+
import { cn } from "../../utils"
|
|
7
|
+
import { CopyCommand } from "./copy-command"
|
|
8
|
+
import { NightBand, NightChip } from "./night-band"
|
|
9
|
+
|
|
10
|
+
/* ─── ProductHeader ────────────────────────────────────────────────────────
|
|
11
|
+
Mark, name, kind, headline, promise, the one command, the honest chips. The
|
|
12
|
+
same shape repeated across a catalogue is what makes several product pages
|
|
13
|
+
read as one family rather than N landing pages that happen to share a nav.
|
|
14
|
+
|
|
15
|
+
**Generalised on the way into the package.** The site's version took a
|
|
16
|
+
`Product` out of its own registry and looked its accent up in a table, so it
|
|
17
|
+
couldn't draw anything that wasn't a Spunto product. Here it takes a mark, a
|
|
18
|
+
name, a kind, an accent colour and (optionally) a command — it knows about no
|
|
19
|
+
product at all. Everything product-specific a page wants beside the command
|
|
20
|
+
(a Source link, a docs link, a signup button) goes through `actions`, which
|
|
21
|
+
is also where a link belongs: the package doesn't import a router. */
|
|
22
|
+
|
|
23
|
+
export interface ProductHeaderProps {
|
|
24
|
+
/** The product's logo — any node, usually an inline SVG sized by the caller. */
|
|
25
|
+
mark?: ReactNode
|
|
26
|
+
name: ReactNode
|
|
27
|
+
/** Mono qualifier beside the name: "self-hosted platform", "CLI"… */
|
|
28
|
+
kind?: ReactNode
|
|
29
|
+
headline: ReactNode
|
|
30
|
+
intro?: ReactNode
|
|
31
|
+
/** Short facts under the intro — "MIT", "no account", "one binary"… */
|
|
32
|
+
chips?: string[]
|
|
33
|
+
/** Any CSS colour. Lights the band, the mark, the chips and the `$` prompt. */
|
|
34
|
+
accent?: string
|
|
35
|
+
/** When set, the header draws the two-column layout with a copyable command. */
|
|
36
|
+
command?: string
|
|
37
|
+
commandCaption?: ReactNode
|
|
38
|
+
commandNote?: ReactNode
|
|
39
|
+
/** Buttons and links under the command — the caller renders its own. */
|
|
40
|
+
actions?: ReactNode
|
|
41
|
+
className?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function ProductHeader({
|
|
45
|
+
mark,
|
|
46
|
+
name,
|
|
47
|
+
kind,
|
|
48
|
+
headline,
|
|
49
|
+
intro,
|
|
50
|
+
chips,
|
|
51
|
+
accent = "var(--flame)",
|
|
52
|
+
command,
|
|
53
|
+
commandCaption,
|
|
54
|
+
commandNote,
|
|
55
|
+
actions,
|
|
56
|
+
className,
|
|
57
|
+
}: ProductHeaderProps) {
|
|
58
|
+
const aside = command !== undefined || actions !== undefined
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<NightBand glow={accent} className={className}>
|
|
62
|
+
<div className="mx-auto max-w-6xl px-5 pb-16 pt-28 md:px-10 md:pb-20 md:pt-36">
|
|
63
|
+
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
|
64
|
+
{mark && <span style={{ color: accent }}>{mark}</span>}
|
|
65
|
+
<span className="font-display text-lg font-bold tracking-tight text-white">{name}</span>
|
|
66
|
+
{kind && <span className="font-mono text-[10.5px] uppercase tracking-[0.16em] text-stone-400">{kind}</span>}
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
{/* Full width — the product's one line shouldn't have to share a column
|
|
70
|
+
with the command block and wrap four times.
|
|
71
|
+
|
|
72
|
+
The phone size is fluid, not a fixed 2.5rem: these headlines are
|
|
73
|
+
hand-broken with a `<br />`, so a line is never allowed to wrap, and
|
|
74
|
+
at a fixed 40px a long word ran past the content box of a 320px
|
|
75
|
+
screen — letters off the edge of the band, clipped by its
|
|
76
|
+
`overflow-hidden`. 10vw keeps the longest word inside the gutters at
|
|
77
|
+
every phone width (and leaves room for the fallback face while the
|
|
78
|
+
display font loads), then stops growing at the same 2.5rem. */}
|
|
79
|
+
<h1 className="font-display mt-6 max-w-[19ch] text-[clamp(2rem,10vw,2.5rem)] font-bold leading-[1.0] tracking-[-0.045em] text-white sm:text-5xl xl:text-[3.9rem]">
|
|
80
|
+
{headline}
|
|
81
|
+
</h1>
|
|
82
|
+
|
|
83
|
+
{/* `min-w-0` on both cells, and it is not cosmetic. A grid item's
|
|
84
|
+
automatic minimum size is its min-content width, and the command
|
|
85
|
+
block's `<pre>` doesn't shrink below its longest line: on a phone
|
|
86
|
+
(one column) the track grows past the page and the band clips
|
|
87
|
+
everything in it — invisibly. Capping the track lets the `<pre>`
|
|
88
|
+
scroll on its own, which is what `overflow-x-auto` was there for. */}
|
|
89
|
+
<div className={cn("mt-9 grid gap-10", aside && "lg:grid-cols-[1.05fr_0.95fr] lg:items-start lg:gap-14")}>
|
|
90
|
+
<div className="min-w-0">
|
|
91
|
+
{intro && <p className="max-w-[56ch] text-[15px] leading-relaxed text-stone-300 md:text-base">{intro}</p>}
|
|
92
|
+
{chips && chips.length > 0 && (
|
|
93
|
+
<div className="mt-6 flex flex-wrap gap-2">
|
|
94
|
+
{chips.map((c) => (
|
|
95
|
+
<NightChip key={c} accent={accent}>
|
|
96
|
+
{c}
|
|
97
|
+
</NightChip>
|
|
98
|
+
))}
|
|
99
|
+
</div>
|
|
100
|
+
)}
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
{aside && (
|
|
104
|
+
<div className="min-w-0 lg:pb-1">
|
|
105
|
+
{command !== undefined && (
|
|
106
|
+
<CopyCommand tone="night" command={command} caption={commandCaption} note={commandNote} accent={accent} />
|
|
107
|
+
)}
|
|
108
|
+
{actions && <div className="mt-5 flex flex-wrap items-center gap-3">{actions}</div>}
|
|
109
|
+
</div>
|
|
110
|
+
)}
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
</NightBand>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState, type ReactNode } from "react"
|
|
4
|
+
|
|
5
|
+
import { cn } from "../../utils"
|
|
6
|
+
import { usePrefersReducedMotion } from "./motion"
|
|
7
|
+
|
|
8
|
+
/* ─── Reveal — fade-and-lift into view on scroll ───────────────────────────
|
|
9
|
+
The one animated primitive that made it into the package, and the reason it
|
|
10
|
+
could is that it carries **no motion library**. The site's version was
|
|
11
|
+
`framer-motion` (`useInView` + a `motion.div`); adding that as a hard
|
|
12
|
+
dependency of the design system would weigh on every consumer, including the
|
|
13
|
+
dashboards that never draw a landing page. An IntersectionObserver and a CSS
|
|
14
|
+
transition do the same job here, in ~20 lines.
|
|
15
|
+
|
|
16
|
+
Reduced motion short-circuits to a plain `<div>`: no observer, no transition,
|
|
17
|
+
the content is simply there. */
|
|
18
|
+
|
|
19
|
+
export interface RevealProps {
|
|
20
|
+
children: ReactNode
|
|
21
|
+
className?: string
|
|
22
|
+
/** Stagger, in seconds — give siblings 0, 0.08, 0.16… */
|
|
23
|
+
delay?: number
|
|
24
|
+
/** How far it lifts, in pixels. */
|
|
25
|
+
y?: number
|
|
26
|
+
/** `false` re-hides the element when it leaves the viewport. */
|
|
27
|
+
once?: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function Reveal({ children, className, delay = 0, y = 28, once = true }: RevealProps) {
|
|
31
|
+
const reduced = usePrefersReducedMotion()
|
|
32
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
33
|
+
const [shown, setShown] = useState(false)
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (reduced) return
|
|
37
|
+
const el = ref.current
|
|
38
|
+
if (!el) return
|
|
39
|
+
const io = new IntersectionObserver(
|
|
40
|
+
([entry]) => {
|
|
41
|
+
if (entry.isIntersecting) {
|
|
42
|
+
setShown(true)
|
|
43
|
+
if (once) io.disconnect()
|
|
44
|
+
} else if (!once) {
|
|
45
|
+
setShown(false)
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
// Same threshold as the framer-motion original: the element has to be
|
|
49
|
+
// 80px inside the viewport before it counts as seen.
|
|
50
|
+
{ rootMargin: "-80px" }
|
|
51
|
+
)
|
|
52
|
+
io.observe(el)
|
|
53
|
+
return () => io.disconnect()
|
|
54
|
+
}, [reduced, once])
|
|
55
|
+
|
|
56
|
+
if (reduced) return <div className={className}>{children}</div>
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div
|
|
60
|
+
ref={ref}
|
|
61
|
+
className={cn("transition-[opacity,transform] duration-[600ms] ease-[cubic-bezier(0.22,1,0.36,1)]", className)}
|
|
62
|
+
style={{
|
|
63
|
+
opacity: shown ? 1 : 0,
|
|
64
|
+
transform: shown ? undefined : `translateY(${y}px)`,
|
|
65
|
+
transitionDelay: delay ? `${delay}s` : undefined,
|
|
66
|
+
}}
|
|
67
|
+
>
|
|
68
|
+
{children}
|
|
69
|
+
</div>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Marketing page scaffolding — the static half of the vocabulary.
|
|
2
|
+
//
|
|
3
|
+
// No `"use client"` on purpose: `Section`, `H2`, `Kicker`, `SpecList` and
|
|
4
|
+
// `Disclosure` render no state and hold no effect, so they stay callable from a
|
|
5
|
+
// React Server Component. The package already splits this way for
|
|
6
|
+
// `buttonVariants` / `alertVariants`; the marketing entry keeps the same line —
|
|
7
|
+
// everything interactive lives in its own file with the directive on it
|
|
8
|
+
// (`copy-command.tsx`, `reveal.tsx`, `use-loop-clock.ts`).
|
|
9
|
+
|
|
10
|
+
import type { ReactNode } from "react"
|
|
11
|
+
|
|
12
|
+
import { cn } from "../../utils"
|
|
13
|
+
|
|
14
|
+
/* ─── Section — every chapter opens the same way ────────────────────────────
|
|
15
|
+
A hairline rule, a mono label ("01 — WHAT IT IS"), an optional note pushed to
|
|
16
|
+
the right margin, then the content. Repeating that shape down a page is what
|
|
17
|
+
makes a marketing page read as one document rather than a stack of unrelated
|
|
18
|
+
blocks. */
|
|
19
|
+
|
|
20
|
+
export interface SectionProps {
|
|
21
|
+
/** Anchor id — the section scrolls under a sticky header (`scroll-mt-20`). */
|
|
22
|
+
id?: string
|
|
23
|
+
/** Chapter number, drawn in the accent colour before the title. */
|
|
24
|
+
n?: string
|
|
25
|
+
title: string
|
|
26
|
+
/** Right-hand margin note, hidden below `sm` where there is no margin. */
|
|
27
|
+
note?: ReactNode
|
|
28
|
+
children: ReactNode
|
|
29
|
+
className?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function Section({ id, n, title, note, children, className }: SectionProps) {
|
|
33
|
+
return (
|
|
34
|
+
<section id={id} className={cn("scroll-mt-20 border-t border-border px-5 py-16 md:px-10 md:py-24", className)}>
|
|
35
|
+
<div className="mb-10 flex items-baseline justify-between gap-4 border-b border-border pb-3 font-mono text-[11px] uppercase tracking-[0.18em] text-muted-foreground md:mb-14">
|
|
36
|
+
<span>
|
|
37
|
+
{n && <span className="text-primary">{n}</span>}
|
|
38
|
+
{n && " — "}
|
|
39
|
+
{title}
|
|
40
|
+
</span>
|
|
41
|
+
{note && <span className="hidden text-muted-foreground/50 sm:block">{note}</span>}
|
|
42
|
+
</div>
|
|
43
|
+
{children}
|
|
44
|
+
</section>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* ─── Headline ──────────────────────────────────────────────────────────────
|
|
49
|
+
Display face (Syne) via the `font-display` utility the tokens already
|
|
50
|
+
generate — the kits carried a `style={{ fontFamily: "var(--font-display)" }}`
|
|
51
|
+
object around because the site had no such utility; the package does. */
|
|
52
|
+
|
|
53
|
+
export interface H2Props {
|
|
54
|
+
children: ReactNode
|
|
55
|
+
className?: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function H2({ children, className }: H2Props) {
|
|
59
|
+
return (
|
|
60
|
+
<h2
|
|
61
|
+
className={cn(
|
|
62
|
+
"font-display max-w-[24ch] text-3xl font-bold leading-[1.05] tracking-[-0.03em] md:text-[2.6rem]",
|
|
63
|
+
className
|
|
64
|
+
)}
|
|
65
|
+
>
|
|
66
|
+
{children}
|
|
67
|
+
</h2>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/* ─── Kicker — the mono line above a headline ───────────────────────────────
|
|
72
|
+
One component, not two: the site had `Kicker` (home + product pages) and
|
|
73
|
+
`Eyebrow` (Ads landings) doing the same job at 0.22em and 0.25em tracking.
|
|
74
|
+
The package publishes a single `Kicker`; a page that wants the wider letter
|
|
75
|
+
spacing passes it through `className`. */
|
|
76
|
+
|
|
77
|
+
export interface KickerProps {
|
|
78
|
+
children: ReactNode
|
|
79
|
+
className?: string
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function Kicker({ children, className }: KickerProps) {
|
|
83
|
+
return <p className={cn("font-mono text-[11px] uppercase tracking-[0.22em] text-primary", className)}>{children}</p>
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/* ─── SpecList — facts without bullets ─────────────────────────────────────
|
|
87
|
+
Key/value rows, the default way to list what something *is* on a marketing
|
|
88
|
+
page: denser than a bulleted list and it never invents a verb. */
|
|
89
|
+
|
|
90
|
+
export interface SpecListProps {
|
|
91
|
+
rows: [string, ReactNode][]
|
|
92
|
+
className?: string
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function SpecList({ rows, className }: SpecListProps) {
|
|
96
|
+
return (
|
|
97
|
+
<dl className={cn("border-t border-border", className)}>
|
|
98
|
+
{rows.map(([k, v]) => (
|
|
99
|
+
<div
|
|
100
|
+
key={k}
|
|
101
|
+
className="grid grid-cols-[6.5rem_1fr] gap-4 border-b border-border py-2.5 md:grid-cols-[8.5rem_1fr]"
|
|
102
|
+
>
|
|
103
|
+
<dt className="pt-0.5 font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground/70">{k}</dt>
|
|
104
|
+
{/* `min-w-0 break-words` — the value column carries the odd API name
|
|
105
|
+
(`Input.dispatchMouseEvent`), and a token longer than the track
|
|
106
|
+
would otherwise widen the grid and push the row off a narrow
|
|
107
|
+
phone rather than wrap. */}
|
|
108
|
+
<dd className="min-w-0 break-words text-sm leading-relaxed text-foreground/85">{v}</dd>
|
|
109
|
+
</div>
|
|
110
|
+
))}
|
|
111
|
+
</dl>
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* ─── Disclosure — a row you open ──────────────────────────────────────────
|
|
116
|
+
A marketing page that lays every argument out at once (five timeline entries
|
|
117
|
+
and seven FAQ answers, all expanded) is a wall a visitor bounces off. Closed,
|
|
118
|
+
a chapter is five headlines you scan in four seconds; open, it's the same
|
|
119
|
+
text as before.
|
|
120
|
+
|
|
121
|
+
`<details name>` gives the exclusive-accordion behaviour natively: no state,
|
|
122
|
+
no hydration surface, and it works before the JS lands — which is also why
|
|
123
|
+
this component stays server-renderable. Browsers that don't know `name` yet
|
|
124
|
+
just allow several rows open at once: the same page, less tidy. */
|
|
125
|
+
|
|
126
|
+
export interface DisclosureProps {
|
|
127
|
+
/** Shared by the rows of one accordion — only one of them stays open. */
|
|
128
|
+
group: string
|
|
129
|
+
summary: ReactNode
|
|
130
|
+
/** Small left-hand column: a time, a product, a number. */
|
|
131
|
+
aside?: ReactNode
|
|
132
|
+
defaultOpen?: boolean
|
|
133
|
+
children: ReactNode
|
|
134
|
+
className?: string
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function Disclosure({ group, summary, aside, defaultOpen = false, children, className }: DisclosureProps) {
|
|
138
|
+
return (
|
|
139
|
+
<details name={group} open={defaultOpen} className={cn("group border-b border-border", className)}>
|
|
140
|
+
<summary className="flex cursor-pointer list-none items-baseline gap-4 py-4 transition-colors hover:text-primary [&::-webkit-details-marker]:hidden">
|
|
141
|
+
{/* On a phone the aside sits *above* the headline rather than in a
|
|
142
|
+
column beside it: a 6rem gutter on a 393pt screen wraps every title
|
|
143
|
+
onto two lines. */}
|
|
144
|
+
{aside && <span className="hidden w-[9rem] shrink-0 md:block">{aside}</span>}
|
|
145
|
+
<span className="min-w-0 flex-1">
|
|
146
|
+
{aside && <span className="mb-1.5 block md:hidden">{aside}</span>}
|
|
147
|
+
<span className="font-display block text-[17px] font-bold leading-snug tracking-[-0.02em] md:text-xl">
|
|
148
|
+
{summary}
|
|
149
|
+
</span>
|
|
150
|
+
</span>
|
|
151
|
+
<span
|
|
152
|
+
aria-hidden
|
|
153
|
+
className="mt-1 shrink-0 font-mono text-lg leading-none text-muted-foreground/60 transition-transform group-open:rotate-45"
|
|
154
|
+
>
|
|
155
|
+
+
|
|
156
|
+
</span>
|
|
157
|
+
</summary>
|
|
158
|
+
<div className={cn("pb-6", aside && "md:pl-[10rem]")}>{children}</div>
|
|
159
|
+
</details>
|
|
160
|
+
)
|
|
161
|
+
}
|
package/styles.css
CHANGED
|
@@ -199,6 +199,9 @@
|
|
|
199
199
|
--color-ship: var(--ship);
|
|
200
200
|
--color-run: var(--run);
|
|
201
201
|
--color-flame: var(--flame);
|
|
202
|
+
/* Marketing surfaces — the two fixed dark values (see `--night` below) */
|
|
203
|
+
--color-night: var(--night);
|
|
204
|
+
--color-code-surface: var(--code-surface);
|
|
202
205
|
/* Square-leaning radius scale — driven by --radius (4px base) */
|
|
203
206
|
--radius-sm: calc(var(--radius) * 0.5);
|
|
204
207
|
--radius-md: calc(var(--radius) * 0.75);
|
|
@@ -311,6 +314,26 @@
|
|
|
311
314
|
--sidebar-ring: oklch(0.70 0.20 43);
|
|
312
315
|
}
|
|
313
316
|
|
|
317
|
+
/* ─── Marketing night surfaces — deliberately NOT themed ─────────────────────
|
|
318
|
+
*
|
|
319
|
+
* Two dark values that stay the same in light and dark mode, because they are
|
|
320
|
+
* the *dark moment* of a page rather than a surface that follows the theme:
|
|
321
|
+
*
|
|
322
|
+
* --night the contrast band a marketing page opens on (`NightBand`).
|
|
323
|
+
* Deeper than the dark theme's own background (0.155) so the
|
|
324
|
+
* band still reads as a band when the whole site is dark.
|
|
325
|
+
* --code-surface the warm dark behind a snippet or a copyable command
|
|
326
|
+
* (`CodeBlock`, `CopyCommand`) — the same one every
|
|
327
|
+
* terminal-ish panel of the site uses.
|
|
328
|
+
*
|
|
329
|
+
* They live here, as tokens (`bg-night`, `bg-code-surface`), precisely because
|
|
330
|
+
* they used to be two near-identical `oklch(…)` literals copy-pasted into two
|
|
331
|
+
* component files — which is how a design system ends up with two blacks. */
|
|
332
|
+
:root {
|
|
333
|
+
--night: oklch(0.14 0.013 50);
|
|
334
|
+
--code-surface: oklch(0.16 0.012 52);
|
|
335
|
+
}
|
|
336
|
+
|
|
314
337
|
/* ─── Base layer ─────────────────────────────────────────────────────────── */
|
|
315
338
|
@layer base {
|
|
316
339
|
* {
|
|
@@ -367,6 +390,50 @@
|
|
|
367
390
|
linear-gradient(to bottom, oklch(0.92 0.04 70 / 0.04) 1px, transparent 1px);
|
|
368
391
|
}
|
|
369
392
|
|
|
393
|
+
/* ─── Night band utilities (@spunto/design-system/marketing) ─────────────────
|
|
394
|
+
* The blueprint grid, the drifting light sources and the scrollbar of a `<pre>`
|
|
395
|
+
* sitting on a dark surface. Never themed — like `--night` itself, these paint
|
|
396
|
+
* on a fixed dark band, so there is no `.dark` variant. */
|
|
397
|
+
.night-grid {
|
|
398
|
+
background-image:
|
|
399
|
+
linear-gradient(to right, oklch(0.92 0.04 70 / 0.05) 1px, transparent 1px),
|
|
400
|
+
linear-gradient(to bottom, oklch(0.92 0.04 70 / 0.05) 1px, transparent 1px);
|
|
401
|
+
background-size: 56px 56px;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/* The light source on a night band drifts, slowly enough that you notice it
|
|
405
|
+
only if you stay. Pure CSS on purpose — this is usually the most-viewed
|
|
406
|
+
element of a page and it has no business holding a rAF loop open. */
|
|
407
|
+
@keyframes night-drift-a {
|
|
408
|
+
0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
|
|
409
|
+
50% { transform: translate3d(6%, 4%, 0) scale(1.12); }
|
|
410
|
+
}
|
|
411
|
+
@keyframes night-drift-b {
|
|
412
|
+
0%, 100% { transform: translate3d(0, 0, 0) scale(1.06); }
|
|
413
|
+
50% { transform: translate3d(-7%, -5%, 0) scale(1); }
|
|
414
|
+
}
|
|
415
|
+
.night-glow-a { animation: night-drift-a 26s ease-in-out infinite; }
|
|
416
|
+
.night-glow-b { animation: night-drift-b 34s ease-in-out infinite; }
|
|
417
|
+
|
|
418
|
+
@media (prefers-reduced-motion: reduce) {
|
|
419
|
+
.night-glow-a,
|
|
420
|
+
.night-glow-b {
|
|
421
|
+
animation: none;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/* Scrollable code on a night surface: the global thumb is `--border`, which is
|
|
426
|
+
cream in the light theme and lands as a bright bar under a dark <pre>. */
|
|
427
|
+
.night-scroll::-webkit-scrollbar-thumb {
|
|
428
|
+
background: oklch(1 0 0 / 0.18);
|
|
429
|
+
}
|
|
430
|
+
.night-scroll::-webkit-scrollbar-thumb:hover {
|
|
431
|
+
background: oklch(1 0 0 / 0.3);
|
|
432
|
+
}
|
|
433
|
+
.night-scroll {
|
|
434
|
+
scrollbar-color: oklch(1 0 0 / 0.18) transparent;
|
|
435
|
+
}
|
|
436
|
+
|
|
370
437
|
/* ─── Toast — stacking + enter/exit motion (mounted by SpuntoProvider) ─────
|
|
371
438
|
*
|
|
372
439
|
* The look (colors, spacing, typography) lives on the components as Tailwind
|