@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spunto/design-system",
3
- "version": "0.4.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);