@spunto/design-system 0.4.0 → 0.5.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/package.json +3 -1
- package/src/components/terminal.tsx +156 -0
- package/src/index.ts +5 -0
- package/styles.css +19 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spunto/design-system",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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,8 @@
|
|
|
44
44
|
"typecheck": "tsc --noEmit"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
+
"@xterm/addon-fit": "^0.11.0",
|
|
48
|
+
"@xterm/xterm": "^6.0.0",
|
|
47
49
|
"class-variance-authority": "^0.7.1",
|
|
48
50
|
"clsx": "^2.1.1",
|
|
49
51
|
"tailwind-merge": "^3.6.0"
|
|
@@ -0,0 +1,156 @@
|
|
|
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 TerminalProps {
|
|
46
|
+
className?: string
|
|
47
|
+
fontSize?: number
|
|
48
|
+
scrollback?: number
|
|
49
|
+
/**
|
|
50
|
+
* Wires keystrokes back out and switches the terminal into interactive mode
|
|
51
|
+
* (blinking cursor, stdin enabled). Omit for read-only log output — the
|
|
52
|
+
* common case this primitive was built for.
|
|
53
|
+
*/
|
|
54
|
+
onData?: (data: string) => void
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Bare xterm.js surface: mount, theme, and fit-on-resize — nothing else. It
|
|
59
|
+
* has no opinion on where bytes come from (WebSocket, SSE, a static string);
|
|
60
|
+
* feed it via the `write`/`writeln` ref handle from whatever transport you
|
|
61
|
+
* plug in on top.
|
|
62
|
+
*/
|
|
63
|
+
const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Terminal(
|
|
64
|
+
{ className, fontSize = 13, scrollback = 5000, onData },
|
|
65
|
+
ref
|
|
66
|
+
) {
|
|
67
|
+
const outerRef = useRef<HTMLDivElement>(null)
|
|
68
|
+
const mountRef = useRef<HTMLDivElement>(null)
|
|
69
|
+
const termRef = useRef<XTerm | null>(null)
|
|
70
|
+
|
|
71
|
+
useImperativeHandle(
|
|
72
|
+
ref,
|
|
73
|
+
() => ({
|
|
74
|
+
write: (data) => termRef.current?.write(data),
|
|
75
|
+
writeln: (data) => termRef.current?.writeln(data),
|
|
76
|
+
clear: () => termRef.current?.clear(),
|
|
77
|
+
focus: () => termRef.current?.focus(),
|
|
78
|
+
}),
|
|
79
|
+
[]
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (!mountRef.current) return
|
|
84
|
+
|
|
85
|
+
let disposed = false
|
|
86
|
+
let resizeObserver: ResizeObserver | undefined
|
|
87
|
+
|
|
88
|
+
async function init() {
|
|
89
|
+
const { Terminal: XTermCtor } = await import("@xterm/xterm")
|
|
90
|
+
const { FitAddon } = await import("@xterm/addon-fit")
|
|
91
|
+
if (disposed || !mountRef.current) return
|
|
92
|
+
|
|
93
|
+
const term = new XTermCtor({
|
|
94
|
+
cursorBlink: !!onData,
|
|
95
|
+
disableStdin: !onData,
|
|
96
|
+
cursorStyle: "block",
|
|
97
|
+
fontFamily: '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace',
|
|
98
|
+
fontSize,
|
|
99
|
+
lineHeight: 1.4,
|
|
100
|
+
scrollback,
|
|
101
|
+
theme: terminalTheme,
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
const fitAddon = new FitAddon()
|
|
105
|
+
term.loadAddon(fitAddon)
|
|
106
|
+
// FitAddon computes available space from *this* element's parent minus
|
|
107
|
+
// this element's own padding — it has no idea about an outer wrapper's
|
|
108
|
+
// padding/border. Mounting on a padding-less, border-less inner div (see
|
|
109
|
+
// render below) means that math actually lines up with what's visually
|
|
110
|
+
// available; mounting directly on the styled/padded outer div is what
|
|
111
|
+
// let `.xterm-screen` spill a few px past the rounded border.
|
|
112
|
+
term.open(mountRef.current)
|
|
113
|
+
fitAddon.fit()
|
|
114
|
+
termRef.current = term
|
|
115
|
+
|
|
116
|
+
if (onData) term.onData(onData)
|
|
117
|
+
|
|
118
|
+
resizeObserver = new ResizeObserver(() => fitAddon.fit())
|
|
119
|
+
resizeObserver.observe(mountRef.current)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
init()
|
|
123
|
+
|
|
124
|
+
return () => {
|
|
125
|
+
disposed = true
|
|
126
|
+
resizeObserver?.disconnect()
|
|
127
|
+
termRef.current?.dispose()
|
|
128
|
+
termRef.current = null
|
|
129
|
+
}
|
|
130
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
131
|
+
}, [])
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<div
|
|
135
|
+
ref={outerRef}
|
|
136
|
+
data-slot="terminal"
|
|
137
|
+
className={cn(
|
|
138
|
+
// `[contain:inline-size]` stops this box's (fit-driven, ever-changing)
|
|
139
|
+
// content width from feeding back into an ancestor's shrink-to-fit
|
|
140
|
+
// sizing — without it, a parent with `flex-basis: auto` re-measures
|
|
141
|
+
// this element's content each time fit() grows it, which re-fires the
|
|
142
|
+
// ResizeObserver below and runs away (confirmed: unbounded growth).
|
|
143
|
+
"h-full w-full min-h-0 min-w-0 overflow-hidden rounded-lg border border-border p-2 [contain:inline-size]",
|
|
144
|
+
className
|
|
145
|
+
)}
|
|
146
|
+
style={{ backgroundColor: terminalTheme.background }}
|
|
147
|
+
>
|
|
148
|
+
{/* Padding/border live on the outer div only — FitAddon can't see them
|
|
149
|
+
(see comment above `term.open`), so xterm always mounts on a bare,
|
|
150
|
+
unstyled div that exactly matches the visually available space. */}
|
|
151
|
+
<div ref={mountRef} className="h-full w-full" />
|
|
152
|
+
</div>
|
|
153
|
+
)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
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 } 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);
|