@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 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.4.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);