termstage 0.1.0__tar.gz

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.
@@ -0,0 +1,28 @@
1
+ name: Release to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ build-and-publish:
10
+ name: Build and publish to PyPI
11
+ runs-on: ubuntu-latest
12
+ permissions:
13
+ id-token: write # required for trusted publisher (OIDC)
14
+
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+
18
+ - name: Install uv
19
+ uses: astral-sh/setup-uv@v5
20
+
21
+ - name: Set up Python
22
+ run: uv python install
23
+
24
+ - name: Build package
25
+ run: uv build
26
+
27
+ - name: Publish to PyPI
28
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,39 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.pyo
5
+ *.pyd
6
+ .Python
7
+ *.egg-info/
8
+ dist/
9
+ build/
10
+ *.egg
11
+ *.whl
12
+
13
+ # Virtual environments
14
+ .venv/
15
+ venv/
16
+ env/
17
+
18
+ # uv
19
+ .uv/
20
+ uv.lock
21
+
22
+ # IDEs
23
+ .idea/
24
+ .vscode/
25
+ *.swp
26
+ *.swo
27
+
28
+ # OS
29
+ .DS_Store
30
+ Thumbs.db
31
+
32
+ # Output SVGs (examples)
33
+ *.svg
34
+ !examples/*.svg
35
+
36
+ # Test artifacts
37
+ .pytest_cache/
38
+ .coverage
39
+ htmlcov/
@@ -0,0 +1 @@
1
+ 3.11
@@ -0,0 +1,17 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented here.
4
+ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
5
+ Versions follow [Semantic Versioning](https://semver.org/).
6
+
7
+ ---
8
+
9
+ ## [0.1.0] — 2026-02-25
10
+
11
+ ### Added
12
+ - Initial release
13
+ - YAML-driven terminal demo SVG generation — no recording required
14
+ - `termstage` CLI with `render` command
15
+ - Support for typing, pause, output, and clear actions
16
+ - Typer-based CLI with `--output` flag
17
+ - PyPI packaging via hatchling + uv
@@ -0,0 +1,155 @@
1
+ Metadata-Version: 2.4
2
+ Name: termstage
3
+ Version: 0.1.0
4
+ Summary: Fake terminal demos. YAML in, SVG out. No recording.
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: pyyaml>=6.0
7
+ Requires-Dist: typer>=0.9
8
+ Description-Content-Type: text/markdown
9
+
10
+ # termstage
11
+
12
+ Fake terminal demos. Write a YAML file describing the session, run `termstage render`, get an SVG. No recording, no live shell, no asciinema.
13
+
14
+ Good for README headers and docs where you want to show what a CLI looks like without screenshotting your actual terminal.
15
+
16
+ ---
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pip install termstage
22
+ # or
23
+ uv add termstage
24
+ ```
25
+
26
+ ## Quick Start
27
+
28
+ **1. Write a demo YAML:**
29
+
30
+ ```yaml
31
+ # demo.yaml
32
+ title: "notes — a simple CLI notes app"
33
+ theme: dark
34
+ prompt: "$ "
35
+ width: 700
36
+
37
+ steps:
38
+ - cmd: "notes --version"
39
+ output: "notes 1.0.0"
40
+ - cmd: "notes add 'Fix the login bug before Friday'"
41
+ output: "Added note #1"
42
+ - cmd: "notes list"
43
+ output: |
44
+ #1 Fix the login bug before Friday
45
+ - comment: "# Search works too"
46
+ - cmd: "notes search 'login'"
47
+ output: "#1 Fix the login bug before Friday"
48
+ ```
49
+
50
+ **2. Render:**
51
+
52
+ ```bash
53
+ termstage render demo.yaml # → demo.svg
54
+ termstage render demo.yaml -o out.svg
55
+ termstage render demo.yaml --animated # CSS typewriter animation
56
+ ```
57
+
58
+ **3. Preview:**
59
+
60
+ ```bash
61
+ termstage preview demo.yaml
62
+ ```
63
+
64
+ ---
65
+
66
+ ## Commands
67
+
68
+ | Command | Description |
69
+ |---------|-------------|
70
+ | `termstage render <file.yaml>` | Render static SVG |
71
+ | `termstage render <file.yaml> -o out.svg` | Custom output path |
72
+ | `termstage render <file.yaml> --animated` | Animated CSS typewriter SVG |
73
+ | `termstage init` | Create a starter `demo.yaml` in current directory |
74
+ | `termstage themes` | List available themes |
75
+ | `termstage preview <file.yaml>` | Render and open in browser |
76
+
77
+ ---
78
+
79
+ ## YAML Format
80
+
81
+ ```yaml
82
+ title: "my cli demo" # Window title bar text
83
+ theme: dark # dark | light | dracula | nord
84
+ prompt: "$ " # Prompt string
85
+ width: 700 # SVG width in pixels (default: 700)
86
+
87
+ steps:
88
+ - cmd: "notes --version"
89
+ output: "notes 1.0.0"
90
+
91
+ - cmd: "notes add 'Fix the login bug before Friday'"
92
+ output: "Added note #1"
93
+
94
+ - cmd: "notes --help"
95
+ output: |
96
+ Usage: notes [OPTIONS] COMMAND
97
+ add Add a new note
98
+ list List all notes
99
+ search Search notes by keyword
100
+ done Mark a note as done
101
+
102
+ - comment: "# Search works too"
103
+
104
+ - cmd: "notes search 'login'"
105
+ output: "#1 Fix the login bug before Friday"
106
+ ```
107
+
108
+ Two step types: `cmd` (prompt + command + optional output) and `comment` (no prompt, styled like a code comment).
109
+
110
+ ---
111
+
112
+ ## Themes
113
+
114
+ | Theme | Background | Based on |
115
+ |-------|------------|----------|
116
+ | `dark` | `#1e1e1e` | VS Code Dark+ (default) |
117
+ | `light` | `#ffffff` | Clean light terminal |
118
+ | `dracula` | `#282a36` | Dracula |
119
+ | `nord` | `#2e3440` | Nord |
120
+
121
+ ```bash
122
+ termstage themes
123
+ ```
124
+
125
+ ---
126
+
127
+ ## Animated SVG
128
+
129
+ `--animated` generates a pure CSS animated SVG: commands type out character by character, output fades in after each one, cursor blinks. No JavaScript — works in GitHub READMEs and anywhere SVG is supported.
130
+
131
+ ```bash
132
+ termstage render demo.yaml --animated -o demo-animated.svg
133
+ ```
134
+
135
+ ---
136
+
137
+ ## Window
138
+
139
+ SVGs render with a macOS-style title bar (traffic light dots, centered title, rounded corners). Font stack: JetBrains Mono, Fira Code, Cascadia Code, monospace.
140
+
141
+ ---
142
+
143
+ ## Examples
144
+
145
+ ```bash
146
+ termstage init
147
+ termstage render demo.yaml -o demo.svg
148
+ termstage render demo.yaml --animated -o demo-animated.svg
149
+ ```
150
+
151
+ ---
152
+
153
+ ## License
154
+
155
+ MIT
@@ -0,0 +1,146 @@
1
+ # termstage
2
+
3
+ Fake terminal demos. Write a YAML file describing the session, run `termstage render`, get an SVG. No recording, no live shell, no asciinema.
4
+
5
+ Good for README headers and docs where you want to show what a CLI looks like without screenshotting your actual terminal.
6
+
7
+ ---
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pip install termstage
13
+ # or
14
+ uv add termstage
15
+ ```
16
+
17
+ ## Quick Start
18
+
19
+ **1. Write a demo YAML:**
20
+
21
+ ```yaml
22
+ # demo.yaml
23
+ title: "notes — a simple CLI notes app"
24
+ theme: dark
25
+ prompt: "$ "
26
+ width: 700
27
+
28
+ steps:
29
+ - cmd: "notes --version"
30
+ output: "notes 1.0.0"
31
+ - cmd: "notes add 'Fix the login bug before Friday'"
32
+ output: "Added note #1"
33
+ - cmd: "notes list"
34
+ output: |
35
+ #1 Fix the login bug before Friday
36
+ - comment: "# Search works too"
37
+ - cmd: "notes search 'login'"
38
+ output: "#1 Fix the login bug before Friday"
39
+ ```
40
+
41
+ **2. Render:**
42
+
43
+ ```bash
44
+ termstage render demo.yaml # → demo.svg
45
+ termstage render demo.yaml -o out.svg
46
+ termstage render demo.yaml --animated # CSS typewriter animation
47
+ ```
48
+
49
+ **3. Preview:**
50
+
51
+ ```bash
52
+ termstage preview demo.yaml
53
+ ```
54
+
55
+ ---
56
+
57
+ ## Commands
58
+
59
+ | Command | Description |
60
+ |---------|-------------|
61
+ | `termstage render <file.yaml>` | Render static SVG |
62
+ | `termstage render <file.yaml> -o out.svg` | Custom output path |
63
+ | `termstage render <file.yaml> --animated` | Animated CSS typewriter SVG |
64
+ | `termstage init` | Create a starter `demo.yaml` in current directory |
65
+ | `termstage themes` | List available themes |
66
+ | `termstage preview <file.yaml>` | Render and open in browser |
67
+
68
+ ---
69
+
70
+ ## YAML Format
71
+
72
+ ```yaml
73
+ title: "my cli demo" # Window title bar text
74
+ theme: dark # dark | light | dracula | nord
75
+ prompt: "$ " # Prompt string
76
+ width: 700 # SVG width in pixels (default: 700)
77
+
78
+ steps:
79
+ - cmd: "notes --version"
80
+ output: "notes 1.0.0"
81
+
82
+ - cmd: "notes add 'Fix the login bug before Friday'"
83
+ output: "Added note #1"
84
+
85
+ - cmd: "notes --help"
86
+ output: |
87
+ Usage: notes [OPTIONS] COMMAND
88
+ add Add a new note
89
+ list List all notes
90
+ search Search notes by keyword
91
+ done Mark a note as done
92
+
93
+ - comment: "# Search works too"
94
+
95
+ - cmd: "notes search 'login'"
96
+ output: "#1 Fix the login bug before Friday"
97
+ ```
98
+
99
+ Two step types: `cmd` (prompt + command + optional output) and `comment` (no prompt, styled like a code comment).
100
+
101
+ ---
102
+
103
+ ## Themes
104
+
105
+ | Theme | Background | Based on |
106
+ |-------|------------|----------|
107
+ | `dark` | `#1e1e1e` | VS Code Dark+ (default) |
108
+ | `light` | `#ffffff` | Clean light terminal |
109
+ | `dracula` | `#282a36` | Dracula |
110
+ | `nord` | `#2e3440` | Nord |
111
+
112
+ ```bash
113
+ termstage themes
114
+ ```
115
+
116
+ ---
117
+
118
+ ## Animated SVG
119
+
120
+ `--animated` generates a pure CSS animated SVG: commands type out character by character, output fades in after each one, cursor blinks. No JavaScript — works in GitHub READMEs and anywhere SVG is supported.
121
+
122
+ ```bash
123
+ termstage render demo.yaml --animated -o demo-animated.svg
124
+ ```
125
+
126
+ ---
127
+
128
+ ## Window
129
+
130
+ SVGs render with a macOS-style title bar (traffic light dots, centered title, rounded corners). Font stack: JetBrains Mono, Fira Code, Cascadia Code, monospace.
131
+
132
+ ---
133
+
134
+ ## Examples
135
+
136
+ ```bash
137
+ termstage init
138
+ termstage render demo.yaml -o demo.svg
139
+ termstage render demo.yaml --animated -o demo-animated.svg
140
+ ```
141
+
142
+ ---
143
+
144
+ ## License
145
+
146
+ MIT
@@ -0,0 +1,27 @@
1
+ title: "notes — a simple CLI notes app"
2
+ theme: dark
3
+ prompt: "$ "
4
+ width: 700
5
+
6
+ steps:
7
+ - cmd: "notes --version"
8
+ output: "notes 1.0.0"
9
+
10
+ - cmd: "notes add 'Fix the login bug before Friday'"
11
+ output: "Added note #1"
12
+
13
+ - cmd: "notes add 'Review pull request from @alice'"
14
+ output: "Added note #2"
15
+
16
+ - cmd: "notes list"
17
+ output: |
18
+ #1 Fix the login bug before Friday
19
+ #2 Review pull request from @alice
20
+
21
+ - comment: "# Search works too"
22
+
23
+ - cmd: "notes search 'login'"
24
+ output: "#1 Fix the login bug before Friday"
25
+
26
+ - cmd: "notes done 1"
27
+ output: "Marked #1 as done"
@@ -0,0 +1,20 @@
1
+ [project]
2
+ name = "termstage"
3
+ version = "0.1.0"
4
+ description = "Fake terminal demos. YAML in, SVG out. No recording."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "typer>=0.9",
9
+ "pyyaml>=6.0",
10
+ ]
11
+
12
+ [project.scripts]
13
+ termstage = "termstage.cli:app"
14
+
15
+ [build-system]
16
+ requires = ["hatchling"]
17
+ build-backend = "hatchling.build"
18
+
19
+ [tool.hatch.build.targets.wheel]
20
+ packages = ["src/termstage"]
@@ -0,0 +1,3 @@
1
+ """termstage — Generate polished terminal demo SVGs from YAML."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,257 @@
1
+ """CSS keyframe-based animated SVG generation for termstage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ from typing import Any
7
+
8
+ from .themes import (
9
+ COMMENT_COLOR,
10
+ FONT_FAMILY,
11
+ FONT_SIZE,
12
+ LINE_HEIGHT,
13
+ OUTPUT_COLOR,
14
+ PADDING,
15
+ THEMES,
16
+ TITLE_BAR_HEIGHT,
17
+ )
18
+
19
+ # Characters per second for typing animation
20
+ CHARS_PER_SEC = 30
21
+ # Seconds for output fade-in
22
+ FADE_DURATION = 0.4
23
+ # Seconds pause after each step before next starts
24
+ STEP_PAUSE = 0.3
25
+
26
+
27
+ def _escape(text: str) -> str:
28
+ return html.escape(text, quote=True)
29
+
30
+
31
+ def _count_lines(steps: list[dict]) -> int:
32
+ total = 0
33
+ for i, step in enumerate(steps):
34
+ if "comment" in step:
35
+ total += 1
36
+ elif "cmd" in step:
37
+ total += 1
38
+ output = step.get("output", "")
39
+ if output:
40
+ total += len(output.rstrip("\n").split("\n"))
41
+ if i < len(steps) - 1:
42
+ total += 1
43
+ return total
44
+
45
+
46
+ def _render_title_bar(width: int, title: str, theme: dict) -> str:
47
+ dots_y = TITLE_BAR_HEIGHT // 2
48
+ dots_html = ""
49
+ if theme.get("dots", True):
50
+ dot_colors = ["#ff5f57", "#febc2e", "#28c840"]
51
+ dot_x_start = 16
52
+ dot_spacing = 20
53
+ for i, color in enumerate(dot_colors):
54
+ cx = dot_x_start + i * dot_spacing
55
+ dots_html += f'<circle cx="{cx}" cy="{dots_y}" r="6" fill="{color}" />\n '
56
+
57
+ title_x = width // 2
58
+ title_svg = (
59
+ f'<text x="{title_x}" y="{dots_y + 5}" '
60
+ f'font-family={FONT_FAMILY!r} font-size="13" '
61
+ f'fill="{theme["text"]}" opacity="0.7" '
62
+ f'text-anchor="middle" dominant-baseline="middle">'
63
+ f"{_escape(title)}</text>"
64
+ )
65
+
66
+ return f""" <!-- Title bar -->
67
+ <rect x="0" y="0" width="{width}" height="{TITLE_BAR_HEIGHT}"
68
+ rx="8" ry="8" fill="{theme['title_bar']}" />
69
+ <rect x="0" y="{TITLE_BAR_HEIGHT - 8}" width="{width}" height="8"
70
+ fill="{theme['title_bar']}" />
71
+ {dots_html}{title_svg}"""
72
+
73
+
74
+ def _make_keyframes(anim_id: str, n_chars: int, start_s: float, duration_s: float) -> str:
75
+ """Generate @keyframes for a typewriter clip-path width expansion."""
76
+ # We use a clipPath trick: text is clipped by a rect whose width grows.
77
+ # Each character is ~ch wide; we animate max-width via clip-path.
78
+ # Actually we'll use the 'steps()' timing function with clip-path width.
79
+ # The animation: from width=0 to width=full_width over duration_s
80
+ # with steps(n_chars, end) for discrete character reveals.
81
+ steps_fn = f"steps({max(n_chars, 1)}, end)"
82
+ delay_s = start_s
83
+
84
+ return (
85
+ f" @keyframes type-{anim_id} {{\n"
86
+ f" from {{ clip-path: inset(0 100% 0 0); }}\n"
87
+ f" to {{ clip-path: inset(0 0% 0 0); }}\n"
88
+ f" }}\n"
89
+ f" .type-{anim_id} {{\n"
90
+ f" clip-path: inset(0 100% 0 0);\n"
91
+ f" animation: type-{anim_id} {duration_s:.3f}s {steps_fn} {delay_s:.3f}s forwards;\n"
92
+ f" }}\n"
93
+ )
94
+
95
+
96
+ def _make_fade_keyframes(anim_id: str, start_s: float) -> str:
97
+ return (
98
+ f" @keyframes fade-{anim_id} {{\n"
99
+ f" from {{ opacity: 0; }}\n"
100
+ f" to {{ opacity: 1; }}\n"
101
+ f" }}\n"
102
+ f" .fade-{anim_id} {{\n"
103
+ f" opacity: 0;\n"
104
+ f" animation: fade-{anim_id} {FADE_DURATION:.2f}s ease {start_s:.3f}s forwards;\n"
105
+ f" }}\n"
106
+ )
107
+
108
+
109
+ def _make_cursor_keyframes(anim_id: str, start_s: float, end_s: float) -> str:
110
+ """Cursor blinks during typing, then disappears."""
111
+ return (
112
+ f" @keyframes blink-{anim_id} {{\n"
113
+ f" 0%, 49% {{ opacity: 1; }}\n"
114
+ f" 50%, 100% {{ opacity: 0; }}\n"
115
+ f" }}\n"
116
+ f" .cursor-{anim_id} {{\n"
117
+ f" opacity: 0;\n"
118
+ f" animation:\n"
119
+ f" blink-{anim_id} 0.6s step-end {start_s:.3f}s {int((end_s - start_s) / 0.6) + 1},\n"
120
+ f" fade-{anim_id}-out 0.01s linear {end_s:.3f}s forwards;\n"
121
+ f" }}\n"
122
+ f" @keyframes fade-{anim_id}-out {{\n"
123
+ f" to {{ opacity: 0; }}\n"
124
+ f" }}\n"
125
+ )
126
+
127
+
128
+ def render_animated_svg(config: dict[str, Any]) -> str:
129
+ """
130
+ Render an animated SVG terminal window from config dict.
131
+ Uses CSS @keyframes for typewriter + fade-in effects. No JS.
132
+ """
133
+ title = config.get("title", "termstage demo")
134
+ theme_name = config.get("theme", "dark")
135
+ prompt = config.get("prompt", "$ ")
136
+ width = int(config.get("width", 700))
137
+ steps = config.get("steps", [])
138
+
139
+ theme = THEMES.get(theme_name, THEMES["dark"])
140
+
141
+ n_lines = _count_lines(steps)
142
+ body_height = PADDING + n_lines * LINE_HEIGHT + PADDING
143
+ total_height = TITLE_BAR_HEIGHT + body_height
144
+
145
+ keyframes_parts: list[str] = []
146
+ elements: list[str] = []
147
+
148
+ y = TITLE_BAR_HEIGHT + PADDING + LINE_HEIGHT
149
+ time_cursor = 0.5 # slight initial pause
150
+
151
+ anim_counter = 0
152
+
153
+ for i, step in enumerate(steps):
154
+ if "comment" in step:
155
+ comment_text = step["comment"]
156
+ aid = f"c{anim_counter}"
157
+ anim_counter += 1
158
+ n_chars = len(comment_text)
159
+ duration = n_chars / CHARS_PER_SEC
160
+
161
+ keyframes_parts.append(_make_keyframes(aid, n_chars, time_cursor, duration))
162
+ time_cursor += duration
163
+
164
+ elements.append(
165
+ f' <text x="{PADDING}" y="{y}" '
166
+ f'font-family={FONT_FAMILY!r} font-size="{FONT_SIZE}" '
167
+ f'fill="{COMMENT_COLOR}" xml:space="preserve" class="type-{aid}">'
168
+ f"{_escape(comment_text)}</text>"
169
+ )
170
+ y += LINE_HEIGHT
171
+ time_cursor += STEP_PAUSE
172
+
173
+ elif "cmd" in step:
174
+ cmd = step["cmd"]
175
+ output = step.get("output", "")
176
+
177
+ full_line = prompt + cmd
178
+ aid = f"cmd{anim_counter}"
179
+ anim_counter += 1
180
+ n_chars = len(full_line)
181
+ duration = n_chars / CHARS_PER_SEC
182
+
183
+ # Cursor appears at start of typing, disappears at end
184
+ cursor_end = time_cursor + duration
185
+ keyframes_parts.append(_make_keyframes(aid, n_chars, time_cursor, duration))
186
+ keyframes_parts.append(
187
+ _make_cursor_keyframes(aid, time_cursor, cursor_end)
188
+ )
189
+
190
+ # Cursor x position: after the text. Approximate at font-size * 0.6 per char
191
+ char_width = FONT_SIZE * 0.61
192
+ cursor_x = PADDING + n_chars * char_width
193
+
194
+ elements.append(
195
+ f' <text x="{PADDING}" y="{y}" '
196
+ f'font-family={FONT_FAMILY!r} font-size="{FONT_SIZE}" '
197
+ f'xml:space="preserve" class="type-{aid}">'
198
+ f'<tspan fill="{theme["prompt"]}">{_escape(prompt)}</tspan>'
199
+ f'<tspan fill="{theme["text"]}">{_escape(cmd)}</tspan>'
200
+ f"</text>"
201
+ )
202
+ # Cursor rect (blinking block)
203
+ elements.append(
204
+ f' <rect x="{cursor_x:.1f}" y="{y - FONT_SIZE}" '
205
+ f'width="{char_width:.1f}" height="{FONT_SIZE + 2}" '
206
+ f'fill="{theme["text"]}" opacity="0.7" class="cursor-{aid}" />'
207
+ )
208
+
209
+ time_cursor = cursor_end
210
+ y += LINE_HEIGHT
211
+
212
+ if output:
213
+ output_lines = output.rstrip("\n").split("\n")
214
+ # Fade in all output lines together
215
+ oid = f"out{anim_counter}"
216
+ anim_counter += 1
217
+ keyframes_parts.append(_make_fade_keyframes(oid, time_cursor))
218
+ time_cursor += FADE_DURATION
219
+
220
+ for line in output_lines:
221
+ elements.append(
222
+ f' <text x="{PADDING}" y="{y}" '
223
+ f'font-family={FONT_FAMILY!r} font-size="{FONT_SIZE}" '
224
+ f'fill="{OUTPUT_COLOR}" xml:space="preserve" class="fade-{oid}">'
225
+ f"{_escape(line)}</text>"
226
+ )
227
+ y += LINE_HEIGHT
228
+
229
+ # blank gap between steps
230
+ if i < len(steps) - 1:
231
+ y += LINE_HEIGHT
232
+ time_cursor += STEP_PAUSE
233
+
234
+ title_bar_svg = _render_title_bar(width, title, theme)
235
+ keyframes_css = "\n".join(keyframes_parts)
236
+ elements_joined = "\n".join(elements)
237
+
238
+ svg = f"""<svg xmlns="http://www.w3.org/2000/svg"
239
+ width="{width}" height="{total_height}"
240
+ viewBox="0 0 {width} {total_height}">
241
+ <style>
242
+ {keyframes_css}
243
+ </style>
244
+ <!-- Window frame -->
245
+ <rect x="0" y="0" width="{width}" height="{total_height}"
246
+ rx="8" ry="8" fill="{theme['bg']}" />
247
+ {title_bar_svg}
248
+ <!-- Terminal body -->
249
+ <clipPath id="body-clip">
250
+ <rect x="0" y="{TITLE_BAR_HEIGHT}" width="{width}" height="{body_height}" />
251
+ </clipPath>
252
+ <g clip-path="url(#body-clip)">
253
+ {elements_joined}
254
+ </g>
255
+ </svg>
256
+ """
257
+ return svg
@@ -0,0 +1,160 @@
1
+ """termstage CLI — typer app."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Annotated, Optional
9
+
10
+ import typer
11
+ import yaml
12
+
13
+ from .animator import render_animated_svg
14
+ from .renderer import render_svg
15
+ from .themes import THEMES
16
+
17
+ app = typer.Typer(
18
+ name="termstage",
19
+ help="Generate polished terminal demo SVGs from YAML — no recording required.",
20
+ add_completion=False,
21
+ )
22
+
23
+
24
+ def _load_yaml(path: Path) -> dict:
25
+ try:
26
+ with path.open() as f:
27
+ return yaml.safe_load(f)
28
+ except FileNotFoundError:
29
+ typer.echo(f"Error: file not found: {path}", err=True)
30
+ raise typer.Exit(1)
31
+ except yaml.YAMLError as e:
32
+ typer.echo(f"Error: invalid YAML: {e}", err=True)
33
+ raise typer.Exit(1)
34
+
35
+
36
+ @app.command()
37
+ def render(
38
+ input_file: Annotated[Path, typer.Argument(help="Input YAML file")],
39
+ output: Annotated[
40
+ Optional[Path],
41
+ typer.Option("-o", "--output", help="Output SVG file (default: <input>.svg)"),
42
+ ] = None,
43
+ animated: Annotated[
44
+ bool, typer.Option("--animated", help="Generate animated CSS typewriter SVG")
45
+ ] = False,
46
+ ):
47
+ """Render a terminal demo SVG from a YAML file."""
48
+ config = _load_yaml(input_file)
49
+
50
+ if animated:
51
+ svg_content = render_animated_svg(config)
52
+ else:
53
+ svg_content = render_svg(config)
54
+
55
+ if output is None:
56
+ output = input_file.with_suffix(".svg")
57
+
58
+ output.write_text(svg_content, encoding="utf-8")
59
+ typer.echo(f"✅ Rendered {'animated ' if animated else ''}SVG → {output}")
60
+
61
+
62
+ @app.command()
63
+ def init(
64
+ output: Annotated[
65
+ Path,
66
+ typer.Argument(help="Output YAML file (default: demo.yaml)"),
67
+ ] = Path("demo.yaml"),
68
+ ):
69
+ """Scaffold a demo.yaml in the current directory."""
70
+ if output.exists():
71
+ overwrite = typer.confirm(f"{output} already exists. Overwrite?")
72
+ if not overwrite:
73
+ raise typer.Exit(0)
74
+
75
+ content = """\
76
+ title: "My CLI Demo"
77
+ theme: dark # dark | light | dracula | nord
78
+ prompt: "$ "
79
+ width: 700
80
+
81
+ steps:
82
+ - comment: "# Welcome to termstage — terminal demos without recording"
83
+
84
+ - cmd: "mytool encode 37.7749 -122.4194"
85
+ output: "8928308280fffff"
86
+
87
+ - cmd: "mytool --help"
88
+ output: |
89
+ Usage: mytool [OPTIONS] COMMAND
90
+
91
+ encode Encode lat/lng to H3 cell ID
92
+ decode Decode H3 cell ID to lat/lng
93
+
94
+ Options:
95
+ --help Show this message and exit.
96
+
97
+ - comment: "# Supports batch mode too"
98
+
99
+ - cmd: "mytool encode --batch coords.csv"
100
+ output: "Processed 1000 rows → output.jsonl"
101
+ """
102
+ output.write_text(content, encoding="utf-8")
103
+ typer.echo(f"✅ Created {output}")
104
+ typer.echo("Run: termstage render demo.yaml")
105
+
106
+
107
+ @app.command()
108
+ def themes():
109
+ """List available themes."""
110
+ typer.echo("Available themes:\n")
111
+ for name, config in THEMES.items():
112
+ bg = config["bg"]
113
+ prompt = config["prompt"]
114
+ typer.echo(f" {name:<10} bg={bg} prompt={prompt}")
115
+ typer.echo(
116
+ "\nUsage: set `theme: <name>` in your YAML file, or pass --theme on the CLI."
117
+ )
118
+
119
+
120
+ @app.command()
121
+ def preview(
122
+ input_file: Annotated[Path, typer.Argument(help="Input YAML file")],
123
+ animated: Annotated[
124
+ bool, typer.Option("--animated", help="Generate animated CSS typewriter SVG")
125
+ ] = False,
126
+ ):
127
+ """Render the SVG and open it in the default browser."""
128
+ import tempfile
129
+
130
+ config = _load_yaml(input_file)
131
+
132
+ if animated:
133
+ svg_content = render_animated_svg(config)
134
+ else:
135
+ svg_content = render_svg(config)
136
+
137
+ # Write to a temp file and open it
138
+ suffix = ".svg"
139
+ with tempfile.NamedTemporaryFile(
140
+ delete=False, suffix=suffix, mode="w", encoding="utf-8"
141
+ ) as f:
142
+ f.write(svg_content)
143
+ tmp_path = f.name
144
+
145
+ typer.echo(f"Opening preview: {tmp_path}")
146
+
147
+ try:
148
+ if sys.platform == "darwin":
149
+ subprocess.run(["open", tmp_path], check=True)
150
+ elif sys.platform == "win32":
151
+ subprocess.run(["start", tmp_path], shell=True, check=True)
152
+ else:
153
+ subprocess.run(["xdg-open", tmp_path], check=True)
154
+ except (subprocess.CalledProcessError, FileNotFoundError) as e:
155
+ typer.echo(f"Could not open browser: {e}", err=True)
156
+ typer.echo(f"SVG saved at: {tmp_path}")
157
+
158
+
159
+ if __name__ == "__main__":
160
+ app()
@@ -0,0 +1,159 @@
1
+ """Pure Python SVG generation for termstage (static rendering)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ from typing import Any
7
+
8
+ from .themes import (
9
+ COMMENT_COLOR,
10
+ FONT_FAMILY,
11
+ FONT_SIZE,
12
+ LINE_HEIGHT,
13
+ OUTPUT_COLOR,
14
+ PADDING,
15
+ THEMES,
16
+ TITLE_BAR_HEIGHT,
17
+ )
18
+
19
+
20
+ def _escape(text: str) -> str:
21
+ """HTML-escape text for safe SVG embedding."""
22
+ return html.escape(text, quote=True)
23
+
24
+
25
+ def _count_lines(steps: list[dict]) -> int:
26
+ """Count total rendered lines across all steps."""
27
+ total = 0
28
+ for i, step in enumerate(steps):
29
+ if "comment" in step:
30
+ total += 1
31
+ elif "cmd" in step:
32
+ total += 1 # prompt + cmd line
33
+ output = step.get("output", "")
34
+ if output:
35
+ total += len(output.rstrip("\n").split("\n"))
36
+ # blank line gap between steps (not after last)
37
+ if i < len(steps) - 1:
38
+ total += 1
39
+ return total
40
+
41
+
42
+ def _render_title_bar(width: int, title: str, theme: dict) -> str:
43
+ """Render the macOS-style title bar."""
44
+ bar_y = 0
45
+ dots_y = TITLE_BAR_HEIGHT // 2
46
+
47
+ dots_html = ""
48
+ if theme.get("dots", True):
49
+ dot_colors = ["#ff5f57", "#febc2e", "#28c840"]
50
+ dot_x_start = 16
51
+ dot_spacing = 20
52
+ for i, color in enumerate(dot_colors):
53
+ cx = dot_x_start + i * dot_spacing
54
+ dots_html += (
55
+ f'<circle cx="{cx}" cy="{dots_y}" r="6" fill="{color}" />\n '
56
+ )
57
+
58
+ title_x = width // 2
59
+ title_svg = (
60
+ f'<text x="{title_x}" y="{dots_y + 5}" '
61
+ f'font-family={FONT_FAMILY!r} font-size="13" '
62
+ f'fill="{theme["text"]}" opacity="0.7" '
63
+ f'text-anchor="middle" dominant-baseline="middle">'
64
+ f"{_escape(title)}</text>"
65
+ )
66
+
67
+ return f""" <!-- Title bar -->
68
+ <rect x="0" y="{bar_y}" width="{width}" height="{TITLE_BAR_HEIGHT}"
69
+ rx="8" ry="8" fill="{theme['title_bar']}" />
70
+ <!-- Fake bottom corners for title bar -->
71
+ <rect x="0" y="{TITLE_BAR_HEIGHT - 8}" width="{width}" height="8"
72
+ fill="{theme['title_bar']}" />
73
+ {dots_html}{title_svg}"""
74
+
75
+
76
+ def render_svg(config: dict[str, Any]) -> str:
77
+ """
78
+ Render a static SVG terminal window from config dict.
79
+
80
+ config keys:
81
+ title, theme, prompt, width, steps
82
+ """
83
+ title = config.get("title", "termstage demo")
84
+ theme_name = config.get("theme", "dark")
85
+ prompt = config.get("prompt", "$ ")
86
+ width = int(config.get("width", 700))
87
+ steps = config.get("steps", [])
88
+
89
+ theme = THEMES.get(theme_name, THEMES["dark"])
90
+
91
+ # Calculate height
92
+ n_lines = _count_lines(steps)
93
+ body_height = PADDING + n_lines * LINE_HEIGHT + PADDING
94
+ total_height = TITLE_BAR_HEIGHT + body_height
95
+
96
+ lines_svg = []
97
+ y = TITLE_BAR_HEIGHT + PADDING + LINE_HEIGHT # baseline y of first line
98
+
99
+ for i, step in enumerate(steps):
100
+ if "comment" in step:
101
+ comment_text = step["comment"]
102
+ lines_svg.append(
103
+ f' <text x="{PADDING}" y="{y}" '
104
+ f'font-family={FONT_FAMILY!r} font-size="{FONT_SIZE}" '
105
+ f'fill="{COMMENT_COLOR}" xml:space="preserve">'
106
+ f"{_escape(comment_text)}</text>"
107
+ )
108
+ y += LINE_HEIGHT
109
+
110
+ elif "cmd" in step:
111
+ cmd = step["cmd"]
112
+ output = step.get("output", "")
113
+
114
+ # Render prompt + command on same line using tspan
115
+ lines_svg.append(
116
+ f' <text x="{PADDING}" y="{y}" '
117
+ f'font-family={FONT_FAMILY!r} font-size="{FONT_SIZE}" '
118
+ f'xml:space="preserve">'
119
+ f'<tspan fill="{theme["prompt"]}">{_escape(prompt)}</tspan>'
120
+ f'<tspan fill="{theme["text"]}">{_escape(cmd)}</tspan>'
121
+ f"</text>"
122
+ )
123
+ y += LINE_HEIGHT
124
+
125
+ if output:
126
+ for line in output.rstrip("\n").split("\n"):
127
+ lines_svg.append(
128
+ f' <text x="{PADDING}" y="{y}" '
129
+ f'font-family={FONT_FAMILY!r} font-size="{FONT_SIZE}" '
130
+ f'fill="{OUTPUT_COLOR}" xml:space="preserve">'
131
+ f"{_escape(line)}</text>"
132
+ )
133
+ y += LINE_HEIGHT
134
+
135
+ # blank gap between steps
136
+ if i < len(steps) - 1:
137
+ y += LINE_HEIGHT
138
+
139
+ title_bar_svg = _render_title_bar(width, title, theme)
140
+ lines_joined = "\n".join(lines_svg)
141
+
142
+ svg = f"""<svg xmlns="http://www.w3.org/2000/svg"
143
+ width="{width}" height="{total_height}"
144
+ viewBox="0 0 {width} {total_height}">
145
+ <!-- Window frame -->
146
+ <rect x="0" y="0" width="{width}" height="{total_height}"
147
+ rx="8" ry="8" fill="{theme['bg']}" />
148
+ {title_bar_svg}
149
+ <!-- Terminal body -->
150
+ <clipPath id="body-clip">
151
+ <rect x="0" y="{TITLE_BAR_HEIGHT}" width="{width}" height="{body_height}"
152
+ rx="0" ry="0" />
153
+ </clipPath>
154
+ <g clip-path="url(#body-clip)">
155
+ {lines_joined}
156
+ </g>
157
+ </svg>
158
+ """
159
+ return svg
@@ -0,0 +1,40 @@
1
+ """Terminal color themes for termstage."""
2
+
3
+ THEMES: dict[str, dict] = {
4
+ "dark": {
5
+ "bg": "#1e1e1e",
6
+ "title_bar": "#323233",
7
+ "text": "#d4d4d4",
8
+ "prompt": "#4ec9b0",
9
+ "dots": True,
10
+ },
11
+ "light": {
12
+ "bg": "#ffffff",
13
+ "title_bar": "#e0e0e0",
14
+ "text": "#333333",
15
+ "prompt": "#007acc",
16
+ "dots": True,
17
+ },
18
+ "dracula": {
19
+ "bg": "#282a36",
20
+ "title_bar": "#44475a",
21
+ "text": "#f8f8f2",
22
+ "prompt": "#50fa7b",
23
+ "dots": True,
24
+ },
25
+ "nord": {
26
+ "bg": "#2e3440",
27
+ "title_bar": "#3b4252",
28
+ "text": "#eceff4",
29
+ "prompt": "#88c0d0",
30
+ "dots": True,
31
+ },
32
+ }
33
+
34
+ COMMENT_COLOR = "#6a9955"
35
+ OUTPUT_COLOR = "#aaaaaa"
36
+ FONT_FAMILY = "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace"
37
+ FONT_SIZE = 14
38
+ LINE_HEIGHT = 22
39
+ TITLE_BAR_HEIGHT = 38
40
+ PADDING = 20