@spunto/design-system 0.15.1 → 0.17.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 +99 -3
- package/package.json +5 -1
- package/src/components/command-menu.tsx +630 -0
- package/src/components/command-palette.tsx +7 -50
- package/src/components/command-shared.tsx +59 -0
- 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/src/components/sheet.tsx +173 -0
- package/src/components/table.tsx +98 -0
- package/src/index.ts +56 -0
- package/styles.css +67 -0
|
@@ -15,6 +15,7 @@ import { Dialog } from "@base-ui/react/dialog"
|
|
|
15
15
|
import { SearchIcon } from "lucide-react"
|
|
16
16
|
|
|
17
17
|
import { cn } from "../utils"
|
|
18
|
+
import { highlightMatch, type CommandFilter, type CommandFilterItem } from "./command-shared"
|
|
18
19
|
import { Kbd } from "./kbd"
|
|
19
20
|
import { Skeleton } from "./skeleton"
|
|
20
21
|
import { Spinner } from "./spinner"
|
|
@@ -41,17 +42,13 @@ import { useOverlayContainer } from "./spunto-provider"
|
|
|
41
42
|
// already-filtered items in.
|
|
42
43
|
// ---------------------------------------------------------------------------
|
|
43
44
|
|
|
44
|
-
/**
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
keywords: string[]
|
|
50
|
-
/** The item's `value`, when it has one. */
|
|
51
|
-
value?: string
|
|
52
|
-
}
|
|
45
|
+
/**
|
|
46
|
+
* What the filter gets to look at for one item. Shared with `CommandMenu` — the
|
|
47
|
+
* two components filter identically, only their shell differs.
|
|
48
|
+
*/
|
|
49
|
+
export type CommandPaletteFilterItem = CommandFilterItem
|
|
53
50
|
|
|
54
|
-
export type CommandPaletteFilter =
|
|
51
|
+
export type CommandPaletteFilter = CommandFilter
|
|
55
52
|
|
|
56
53
|
interface PaletteContextValue {
|
|
57
54
|
query: string
|
|
@@ -107,46 +104,6 @@ function comboMatches(event: KeyboardEvent, combo: Combo): boolean {
|
|
|
107
104
|
return true
|
|
108
105
|
}
|
|
109
106
|
|
|
110
|
-
// --- match highlighting -----------------------------------------------------
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Folds a string to a comparable form (lowercase, no diacritics) while keeping,
|
|
114
|
-
* for every folded character, the index it came from — so a match found on the
|
|
115
|
-
* folded text can be highlighted on the original one ("déploiement" matched by
|
|
116
|
-
* "deploi" highlights the right six characters, accent included).
|
|
117
|
-
*/
|
|
118
|
-
function fold(text: string): { folded: string; map: number[] } {
|
|
119
|
-
let folded = ""
|
|
120
|
-
const map: number[] = []
|
|
121
|
-
for (let i = 0; i < text.length; i++) {
|
|
122
|
-
const chunk = text[i].normalize("NFD").replace(/\p{Diacritic}/gu, "").toLowerCase()
|
|
123
|
-
for (const char of chunk) {
|
|
124
|
-
folded += char
|
|
125
|
-
map.push(i)
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
return { folded, map }
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/** Wraps the matched slice of `text` in a `<mark>`. Returns the plain text when there's no match. */
|
|
132
|
-
function highlightMatch(text: string, query: string): ReactNode {
|
|
133
|
-
if (!query.trim()) return text
|
|
134
|
-
const haystack = fold(text)
|
|
135
|
-
const needle = fold(query.trim()).folded
|
|
136
|
-
if (!needle) return text
|
|
137
|
-
const at = haystack.folded.indexOf(needle)
|
|
138
|
-
if (at === -1) return text
|
|
139
|
-
const start = haystack.map[at]
|
|
140
|
-
const end = haystack.map[at + needle.length - 1] + 1
|
|
141
|
-
return (
|
|
142
|
-
<>
|
|
143
|
-
{text.slice(0, start)}
|
|
144
|
-
<mark className="rounded-xs bg-primary/15 text-inherit">{text.slice(start, end)}</mark>
|
|
145
|
-
{text.slice(end)}
|
|
146
|
-
</>
|
|
147
|
-
)
|
|
148
|
-
}
|
|
149
|
-
|
|
150
107
|
// --- root -------------------------------------------------------------------
|
|
151
108
|
|
|
152
109
|
export interface CommandPaletteProps {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ReactNode } from "react"
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// What `CommandPalette` (modal, ⌘K) and `CommandMenu` (inline, anchored) share:
|
|
5
|
+
// how an item describes itself to the filter, and how a match is highlighted.
|
|
6
|
+
// The two components differ in their shell — a focus-trapping Dialog vs. a
|
|
7
|
+
// Popover that never takes focus — not in what "matching" means, so a filter
|
|
8
|
+
// written for one works verbatim on the other.
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
/** What the filter gets to look at for one item. */
|
|
12
|
+
export interface CommandFilterItem {
|
|
13
|
+
/** `label` prop, or the item's children when they're a plain string. */
|
|
14
|
+
label: string
|
|
15
|
+
/** Extra terms that match but are never displayed. */
|
|
16
|
+
keywords: string[]
|
|
17
|
+
/** The item's `value`, when it has one. */
|
|
18
|
+
value?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type CommandFilter = (item: CommandFilterItem, query: string) => boolean
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Folds a string to a comparable form (lowercase, no diacritics) while keeping,
|
|
25
|
+
* for every folded character, the index it came from — so a match found on the
|
|
26
|
+
* folded text can be highlighted on the original one ("déploiement" matched by
|
|
27
|
+
* "deploi" highlights the right six characters, accent included).
|
|
28
|
+
*/
|
|
29
|
+
export function fold(text: string): { folded: string; map: number[] } {
|
|
30
|
+
let folded = ""
|
|
31
|
+
const map: number[] = []
|
|
32
|
+
for (let i = 0; i < text.length; i++) {
|
|
33
|
+
const chunk = text[i].normalize("NFD").replace(/\p{Diacritic}/gu, "").toLowerCase()
|
|
34
|
+
for (const char of chunk) {
|
|
35
|
+
folded += char
|
|
36
|
+
map.push(i)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return { folded, map }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Wraps the matched slice of `text` in a `<mark>`. Returns the plain text when there's no match. */
|
|
43
|
+
export function highlightMatch(text: string, query: string): ReactNode {
|
|
44
|
+
if (!query.trim()) return text
|
|
45
|
+
const haystack = fold(text)
|
|
46
|
+
const needle = fold(query.trim()).folded
|
|
47
|
+
if (!needle) return text
|
|
48
|
+
const at = haystack.folded.indexOf(needle)
|
|
49
|
+
if (at === -1) return text
|
|
50
|
+
const start = haystack.map[at]
|
|
51
|
+
const end = haystack.map[at + needle.length - 1] + 1
|
|
52
|
+
return (
|
|
53
|
+
<>
|
|
54
|
+
{text.slice(0, start)}
|
|
55
|
+
<mark className="rounded-xs bg-primary/15 text-inherit">{text.slice(start, end)}</mark>
|
|
56
|
+
{text.slice(end)}
|
|
57
|
+
</>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
@@ -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
|
+
}
|