@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.
@@ -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
- /** What the filter gets to look at for one item. */
45
- export interface CommandPaletteFilterItem {
46
- /** `label` prop, or the item's children when they're a plain string. */
47
- label: string
48
- /** Extra terms that match but are never displayed. */
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 = (item: CommandPaletteFilterItem, query: string) => boolean
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
+ }