@spunto/design-system 0.15.0 → 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 CHANGED
@@ -4,6 +4,16 @@ Spunto's shared design system — the warm/flame **tokens**, JS **color constant
4
4
  and the base **UI primitives** used across Spunto apps (the dashboard and Spunto
5
5
  Lite). One source of truth so replicated features stay visually consistent.
6
6
 
7
+ > **Built on [shadcn/ui](https://ui.shadcn.com).** This package is an adaptation of
8
+ > shadcn/ui, not a design system written from scratch. Its token names and structure,
9
+ > its `cva` + `cn` variant pattern, and most of its primitives were copied from
10
+ > shadcn/ui (the Base UI variant) and then restyled for the Spunto palette and
11
+ > extended with Spunto-specific components. Full credit for the original design and
12
+ > API goes to [shadcn](https://github.com/shadcn) and the
13
+ > [shadcn-ui/ui](https://github.com/shadcn-ui/ui) contributors — MIT licensed, and
14
+ > the reason this package exists at all. If you're not tied to Spunto's look, use
15
+ > shadcn/ui directly.
16
+
7
17
  ## Usage
8
18
 
9
19
  Import the tokens once in your app's entry CSS, right after Tailwind:
@@ -36,6 +46,9 @@ const nextConfig = { transpilePackages: ["@spunto/design-system"] }
36
46
  → Tailwind color/radius mapping, a minimal base layer, and the `dot-grid` /
37
47
  `grid-lines` background utilities. Brand color is **flame** (`--primary` ≈ `#ea5400`);
38
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.
39
52
  - **`cn`** — `clsx` + `tailwind-merge`.
40
53
  - **`./colors`** — `cssVar`, `chartColors`, `chartRamp`, `segColors` for JS/chart contexts.
41
54
  - **Primitives** — form + layout building blocks styled on `@base-ui/react`:
@@ -102,11 +115,16 @@ import { Button, Card } from "@spunto/design-system"
102
115
  import { WorkerCard } from "@spunto/design-system/workers" // domain
103
116
  import { ImageCard, FeatureCard, ExtensionCard } from "@spunto/design-system/devcontainer" // domain
104
117
  import { ProjectForm, ProjectPanel } from "@spunto/design-system/projects" // domain
118
+ import { Section, ProductHeader } from "@spunto/design-system/marketing" // not a domain — see below
105
119
  ```
106
120
 
107
121
  One entry **per domain**, not per component: a project's form and a project's
108
122
  panel are the same concept seen twice, so they share `/projects`.
109
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
+
110
128
  ### `@spunto/design-system/workers`
111
129
 
112
130
  - **`WorkerCard`** — a Spunto worker as a card: state, author, node, setup
@@ -254,6 +272,72 @@ And the read-only twin of that form:
254
272
  - **Container queries** here too: the panel is 288 px in a sidebar on a 27" screen
255
273
  and full width on a phone — and the desktop case is the narrow one.
256
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
+
257
341
  ## Toasts — `SpuntoProvider` + `toast()`
258
342
 
259
343
  `SpuntoProvider` is the design system's client umbrella provider. Mount it once
@@ -323,6 +407,21 @@ Adding a component = add its entry to `content/components.ts` + a demo file in
323
407
  `showcase/src/demos/`; the visual page and its `.md` are then produced
324
408
  automatically (see `showcase/vite-plugin-llms.ts`).
325
409
 
410
+ ## Credits
411
+
412
+ - **[shadcn/ui](https://ui.shadcn.com)** ([shadcn-ui/ui](https://github.com/shadcn-ui/ui), MIT) —
413
+ **the base of this package.** The token contract (`--background`/`--foreground`,
414
+ `--card`, `--muted-foreground`, `--primary`, `--ring`…), the `cn` helper, the
415
+ `cva` variant pattern and most primitives here started as shadcn/ui components
416
+ (Base UI variant) and were restyled, not reinvented. The parts that are ours are
417
+ the palette values, the `Build`/`Ship`/`Run` semantics, and the Spunto-specific
418
+ components (`Terminal`, `TerminalPanel`, `ImagePull`, and everything under the
419
+ domain sub-exports).
420
+ - **[Base UI](https://base-ui.com)** (MIT) — the unstyled primitives underneath.
421
+ - **[Tailwind CSS](https://tailwindcss.com)** (MIT) — the styling pipeline.
422
+ - **[xterm.js](https://xtermjs.org)** (MIT) — the terminal surface.
423
+ - **[lucide](https://lucide.dev)** (ISC) — the icons.
424
+
326
425
  ## Peer requirements
327
426
 
328
427
  - React 19+, and a **Tailwind CSS v4** pipeline in the consuming app.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spunto/design-system",
3
- "version": "0.15.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
@@ -1,5 +1,8 @@
1
1
  /*
2
- * @spunto/design-system — design tokens (warm / Mistral-inspired, brand: flame).
2
+ * @spunto/design-system — design tokens (warm, brand: flame).
3
+ *
4
+ * Token names and structure follow shadcn/ui (https://ui.shadcn.com, MIT) — the
5
+ * base this whole design system is built on. Only the values are ours.
3
6
  *
4
7
  * Import AFTER `@import "tailwindcss"` in your app's entry CSS:
5
8
  * @import "tailwindcss";
@@ -196,6 +199,9 @@
196
199
  --color-ship: var(--ship);
197
200
  --color-run: var(--run);
198
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);
199
205
  /* Square-leaning radius scale — driven by --radius (4px base) */
200
206
  --radius-sm: calc(var(--radius) * 0.5);
201
207
  --radius-md: calc(var(--radius) * 0.75);
@@ -206,7 +212,7 @@
206
212
  --radius-4xl: calc(var(--radius) * 3.5);
207
213
  }
208
214
 
209
- /* ─── Light mode — Warm cream, Mistral-inspired ─────────────────────────── */
215
+ /* ─── Light mode — Warm cream ───────────────────────────────────────────── */
210
216
  :root {
211
217
  --background: oklch(0.975 0.012 82);
212
218
  --foreground: oklch(0.20 0.018 55);
@@ -308,6 +314,26 @@
308
314
  --sidebar-ring: oklch(0.70 0.20 43);
309
315
  }
310
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
+
311
337
  /* ─── Base layer ─────────────────────────────────────────────────────────── */
312
338
  @layer base {
313
339
  * {
@@ -364,6 +390,50 @@
364
390
  linear-gradient(to bottom, oklch(0.92 0.04 70 / 0.04) 1px, transparent 1px);
365
391
  }
366
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
+
367
437
  /* ─── Toast — stacking + enter/exit motion (mounted by SpuntoProvider) ─────
368
438
  *
369
439
  * The look (colors, spacing, typography) lives on the components as Tailwind