@spunto/design-system 0.4.0 → 0.6.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 +24 -0
- package/package.json +4 -1
- package/src/components/terminal.tsx +188 -0
- package/src/index.ts +5 -0
- package/styles.css +19 -0
package/README.md
CHANGED
|
@@ -51,6 +51,10 @@ const nextConfig = { transpilePackages: ["@spunto/design-system"] }
|
|
|
51
51
|
(a confirmation variant, non-dismissible), `Tooltip`. Their portals render into
|
|
52
52
|
the provider's overlay container; `Tooltip`'s shared delay group is mounted by the
|
|
53
53
|
provider too.
|
|
54
|
+
- _Terminal_ — `Terminal` (+`TerminalHandle`, `TerminalOptions`), a transport-agnostic
|
|
55
|
+
xterm.js surface: mount, shared dark ANSI theme, fit-on-resize. No opinion on where
|
|
56
|
+
bytes come from (WebSocket, SSE, a static string) — feed it via the `write`/`writeln`
|
|
57
|
+
ref handle. Opt-in addons through `options` (currently `webLinks`, clickable URLs).
|
|
54
58
|
- **`SpuntoProvider` + `toast()`** — the imperative toast system (see below). The
|
|
55
59
|
provider is also the umbrella that mounts the tooltip delay group and the overlay
|
|
56
60
|
portal container the dialogs/selects render into.
|
|
@@ -104,6 +108,26 @@ toast.dismiss(id)
|
|
|
104
108
|
`toastPosition` (default `"top-right"`), `toastDuration` (default `5000`, `0` =
|
|
105
109
|
persistent) and `toastLimit` (default `3`).
|
|
106
110
|
|
|
111
|
+
## LLM / agent docs (`llms.txt`)
|
|
112
|
+
|
|
113
|
+
The [showcase](showcase/) also exposes its documentation as **plain markdown**,
|
|
114
|
+
so coding agents can consume the component APIs directly. Everything is generated
|
|
115
|
+
from the same content that drives the visual pages
|
|
116
|
+
(`showcase/src/content/components.ts`) — there is no second source to keep in sync.
|
|
117
|
+
|
|
118
|
+
Served both in dev (Vite middleware) and in the built site (static files) at
|
|
119
|
+
`https://design.spunto.net`:
|
|
120
|
+
|
|
121
|
+
- **`/llms.txt`** — index (llmstxt.org convention) linking every page.
|
|
122
|
+
- **`/<component>.md`** — one page per component with import, example and the full
|
|
123
|
+
props API (e.g. `/button.md`, `/select.md`).
|
|
124
|
+
- **`/colors.md`**, `/typography.md`, `/radius.md` — the foundations/tokens.
|
|
125
|
+
- **`/llms-full.txt`** — every page concatenated into a single file.
|
|
126
|
+
|
|
127
|
+
Adding a component = add its entry to `content/components.ts` + a demo file in
|
|
128
|
+
`showcase/src/demos/`; the visual page and its `.md` are then produced
|
|
129
|
+
automatically (see `showcase/vite-plugin-llms.ts`).
|
|
130
|
+
|
|
107
131
|
## Peer requirements
|
|
108
132
|
|
|
109
133
|
- 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.
|
|
3
|
+
"version": "0.6.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",
|
|
@@ -44,6 +44,9 @@
|
|
|
44
44
|
"typecheck": "tsc --noEmit"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
+
"@xterm/addon-fit": "^0.11.0",
|
|
48
|
+
"@xterm/addon-web-links": "^0.12.0",
|
|
49
|
+
"@xterm/xterm": "^6.0.0",
|
|
47
50
|
"class-variance-authority": "^0.7.1",
|
|
48
51
|
"clsx": "^2.1.1",
|
|
49
52
|
"tailwind-merge": "^3.6.0"
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"
|
|
4
|
+
import type { Terminal as XTerm } from "@xterm/xterm"
|
|
5
|
+
// xterm.js's own stylesheet — hides its input-capture textarea and glyph
|
|
6
|
+
// measurement helpers. A JS-side import (not a CSS `@import`) so it loads
|
|
7
|
+
// regardless of the consuming app's stylesheet pipeline/ordering.
|
|
8
|
+
import "@xterm/xterm/css/xterm.css"
|
|
9
|
+
|
|
10
|
+
import { cn } from "../utils"
|
|
11
|
+
|
|
12
|
+
// Shared dark ANSI palette — the one look every log/terminal surface in the
|
|
13
|
+
// product already uses (job runs, builds, service logs, worker shells).
|
|
14
|
+
const terminalTheme = {
|
|
15
|
+
background: "#09090b",
|
|
16
|
+
foreground: "#e4e4e7",
|
|
17
|
+
cursor: "#a1a1aa",
|
|
18
|
+
selectionBackground: "#3f3f46",
|
|
19
|
+
black: "#18181b",
|
|
20
|
+
red: "#f87171",
|
|
21
|
+
green: "#4ade80",
|
|
22
|
+
yellow: "#facc15",
|
|
23
|
+
blue: "#60a5fa",
|
|
24
|
+
magenta: "#c084fc",
|
|
25
|
+
cyan: "#22d3ee",
|
|
26
|
+
white: "#e4e4e7",
|
|
27
|
+
brightBlack: "#52525b",
|
|
28
|
+
brightRed: "#fca5a5",
|
|
29
|
+
brightGreen: "#86efac",
|
|
30
|
+
brightYellow: "#fde047",
|
|
31
|
+
brightBlue: "#93c5fd",
|
|
32
|
+
brightMagenta: "#d8b4fe",
|
|
33
|
+
brightCyan: "#67e8f9",
|
|
34
|
+
brightWhite: "#f4f4f5",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface TerminalHandle {
|
|
38
|
+
/** Writes raw bytes/text (ANSI sequences included) to the terminal. */
|
|
39
|
+
write: (data: string | Uint8Array) => void
|
|
40
|
+
writeln: (data: string) => void
|
|
41
|
+
clear: () => void
|
|
42
|
+
focus: () => void
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface TerminalOptions {
|
|
46
|
+
/** Turns URLs in terminal output into clickable links (`@xterm/addon-web-links`). */
|
|
47
|
+
webLinks?: boolean
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface TerminalProps {
|
|
51
|
+
className?: string
|
|
52
|
+
fontSize?: number
|
|
53
|
+
scrollback?: number
|
|
54
|
+
/**
|
|
55
|
+
* Wires keystrokes back out and switches the terminal into interactive mode
|
|
56
|
+
* (blinking cursor, stdin enabled). Omit for read-only log output — the
|
|
57
|
+
* common case this primitive was built for.
|
|
58
|
+
*/
|
|
59
|
+
onData?: (data: string) => void
|
|
60
|
+
/**
|
|
61
|
+
* Opt-in xterm addons — kept out of the base bundle since most consumers
|
|
62
|
+
* (plain log output) don't need them. Read once at mount, like the other
|
|
63
|
+
* props here.
|
|
64
|
+
*/
|
|
65
|
+
options?: TerminalOptions
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Bare xterm.js surface: mount, theme, and fit-on-resize — nothing else. It
|
|
70
|
+
* has no opinion on where bytes come from (WebSocket, SSE, a static string);
|
|
71
|
+
* feed it via the `write`/`writeln` ref handle from whatever transport you
|
|
72
|
+
* plug in on top.
|
|
73
|
+
*/
|
|
74
|
+
const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Terminal(
|
|
75
|
+
{ className, fontSize = 13, scrollback = 5000, onData, options },
|
|
76
|
+
ref
|
|
77
|
+
) {
|
|
78
|
+
const outerRef = useRef<HTMLDivElement>(null)
|
|
79
|
+
const mountRef = useRef<HTMLDivElement>(null)
|
|
80
|
+
const termRef = useRef<XTerm | null>(null)
|
|
81
|
+
// xterm mounts asynchronously (dynamic import); a consumer wiring up a
|
|
82
|
+
// transport (WebSocket, SSE...) in its own effect can easily win that race
|
|
83
|
+
// and call write() before termRef exists. Queue those calls instead of
|
|
84
|
+
// dropping them, and flush in order once xterm is ready.
|
|
85
|
+
const pendingRef = useRef<Array<{ method: "write" | "writeln"; data: string | Uint8Array }>>([])
|
|
86
|
+
|
|
87
|
+
useImperativeHandle(
|
|
88
|
+
ref,
|
|
89
|
+
() => ({
|
|
90
|
+
write: (data) => {
|
|
91
|
+
if (termRef.current) termRef.current.write(data)
|
|
92
|
+
else pendingRef.current.push({ method: "write", data })
|
|
93
|
+
},
|
|
94
|
+
writeln: (data) => {
|
|
95
|
+
if (termRef.current) termRef.current.writeln(data)
|
|
96
|
+
else pendingRef.current.push({ method: "writeln", data })
|
|
97
|
+
},
|
|
98
|
+
clear: () => termRef.current?.clear(),
|
|
99
|
+
focus: () => termRef.current?.focus(),
|
|
100
|
+
}),
|
|
101
|
+
[]
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (!mountRef.current) return
|
|
106
|
+
|
|
107
|
+
let disposed = false
|
|
108
|
+
let resizeObserver: ResizeObserver | undefined
|
|
109
|
+
|
|
110
|
+
async function init() {
|
|
111
|
+
const { Terminal: XTermCtor } = await import("@xterm/xterm")
|
|
112
|
+
const { FitAddon } = await import("@xterm/addon-fit")
|
|
113
|
+
if (disposed || !mountRef.current) return
|
|
114
|
+
|
|
115
|
+
const term = new XTermCtor({
|
|
116
|
+
cursorBlink: !!onData,
|
|
117
|
+
disableStdin: !onData,
|
|
118
|
+
cursorStyle: "block",
|
|
119
|
+
fontFamily: '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace',
|
|
120
|
+
fontSize,
|
|
121
|
+
lineHeight: 1.4,
|
|
122
|
+
scrollback,
|
|
123
|
+
theme: terminalTheme,
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
const fitAddon = new FitAddon()
|
|
127
|
+
term.loadAddon(fitAddon)
|
|
128
|
+
if (options?.webLinks) {
|
|
129
|
+
const { WebLinksAddon } = await import("@xterm/addon-web-links")
|
|
130
|
+
term.loadAddon(new WebLinksAddon())
|
|
131
|
+
}
|
|
132
|
+
// FitAddon computes available space from *this* element's parent minus
|
|
133
|
+
// this element's own padding — it has no idea about an outer wrapper's
|
|
134
|
+
// padding/border. Mounting on a padding-less, border-less inner div (see
|
|
135
|
+
// render below) means that math actually lines up with what's visually
|
|
136
|
+
// available; mounting directly on the styled/padded outer div is what
|
|
137
|
+
// let `.xterm-screen` spill a few px past the rounded border.
|
|
138
|
+
term.open(mountRef.current)
|
|
139
|
+
fitAddon.fit()
|
|
140
|
+
termRef.current = term
|
|
141
|
+
|
|
142
|
+
for (const { method, data } of pendingRef.current) {
|
|
143
|
+
if (method === "writeln") term.writeln(data as string)
|
|
144
|
+
else term.write(data)
|
|
145
|
+
}
|
|
146
|
+
pendingRef.current = []
|
|
147
|
+
|
|
148
|
+
if (onData) term.onData(onData)
|
|
149
|
+
|
|
150
|
+
resizeObserver = new ResizeObserver(() => fitAddon.fit())
|
|
151
|
+
resizeObserver.observe(mountRef.current)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
init()
|
|
155
|
+
|
|
156
|
+
return () => {
|
|
157
|
+
disposed = true
|
|
158
|
+
resizeObserver?.disconnect()
|
|
159
|
+
termRef.current?.dispose()
|
|
160
|
+
termRef.current = null
|
|
161
|
+
}
|
|
162
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
163
|
+
}, [])
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
<div
|
|
167
|
+
ref={outerRef}
|
|
168
|
+
data-slot="terminal"
|
|
169
|
+
className={cn(
|
|
170
|
+
// `[contain:inline-size]` stops this box's (fit-driven, ever-changing)
|
|
171
|
+
// content width from feeding back into an ancestor's shrink-to-fit
|
|
172
|
+
// sizing — without it, a parent with `flex-basis: auto` re-measures
|
|
173
|
+
// this element's content each time fit() grows it, which re-fires the
|
|
174
|
+
// ResizeObserver below and runs away (confirmed: unbounded growth).
|
|
175
|
+
"h-full w-full min-h-0 min-w-0 overflow-hidden rounded-lg border border-border p-2 [contain:inline-size]",
|
|
176
|
+
className
|
|
177
|
+
)}
|
|
178
|
+
style={{ backgroundColor: terminalTheme.background }}
|
|
179
|
+
>
|
|
180
|
+
{/* Padding/border live on the outer div only — FitAddon can't see them
|
|
181
|
+
(see comment above `term.open`), so xterm always mounts on a bare,
|
|
182
|
+
unstyled div that exactly matches the visually available space. */}
|
|
183
|
+
<div ref={mountRef} className="h-full w-full" />
|
|
184
|
+
</div>
|
|
185
|
+
)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
export { Terminal }
|
package/src/index.ts
CHANGED
|
@@ -21,6 +21,11 @@ export { Skeleton } from "./components/skeleton"
|
|
|
21
21
|
export { Spinner, spinnerVariants } from "./components/spinner"
|
|
22
22
|
export { Avatar, AvatarImage, AvatarFallback } from "./components/avatar"
|
|
23
23
|
|
|
24
|
+
// xterm.js surface — theme + fit-on-resize, transport-agnostic (feed it via
|
|
25
|
+
// the write/writeln ref handle).
|
|
26
|
+
export { Terminal } from "./components/terminal"
|
|
27
|
+
export type { TerminalHandle, TerminalProps, TerminalOptions } from "./components/terminal"
|
|
28
|
+
|
|
24
29
|
// Inline, declarative status banner (persistent) — the counterpart to `toast()`.
|
|
25
30
|
// `alertVariants` ships from this directive-free module so it stays RSC-callable.
|
|
26
31
|
export { Alert, AlertTitle, AlertDescription, alertVariants } from "./components/alert"
|
package/styles.css
CHANGED
|
@@ -186,6 +186,25 @@
|
|
|
186
186
|
::-webkit-scrollbar-thumb:hover { background: color-mix(in oklch, var(--muted-foreground) 30%, transparent); }
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
+
/* ─── Terminal — xterm.js scrollbar ──────────────────────────────────────────
|
|
190
|
+
* Restyle xterm's own custom scrollbar (14px, square corners) to match the
|
|
191
|
+
* thin/rounded one above. Its width/position are set inline by xterm's JS on
|
|
192
|
+
* every resize, hence `!important`; color is left to xterm's own theme
|
|
193
|
+
* (`scrollbarSliderBackground` etc. in `terminalTheme`, terminal.tsx) since
|
|
194
|
+
* that already defaults to a sensible foreground/20% and is the supported
|
|
195
|
+
* knob for it. */
|
|
196
|
+
[data-slot="terminal"] .scrollbar.vertical,
|
|
197
|
+
[data-slot="terminal"] .scrollbar.vertical > .slider {
|
|
198
|
+
width: 6px !important;
|
|
199
|
+
}
|
|
200
|
+
[data-slot="terminal"] .scrollbar.horizontal,
|
|
201
|
+
[data-slot="terminal"] .scrollbar.horizontal > .slider {
|
|
202
|
+
height: 6px !important;
|
|
203
|
+
}
|
|
204
|
+
[data-slot="terminal"] .slider {
|
|
205
|
+
border-radius: 9999px;
|
|
206
|
+
}
|
|
207
|
+
|
|
189
208
|
/* ─── Warm background utilities ──────────────────────────────────────────── */
|
|
190
209
|
.dot-grid {
|
|
191
210
|
background-image: radial-gradient(circle, oklch(0.45 0.05 50 / 0.08) 1px, transparent 1px);
|