twarner commited on
Commit
1d11bfa
·
1 Parent(s): f479e26

Gradio inference

Browse files
Files changed (6) hide show
  1. README.md +10 -11
  2. app.js +0 -251
  3. app.py +189 -0
  4. index.html +0 -43
  5. requirements.txt +5 -0
  6. style.css +0 -130
README.md CHANGED
@@ -3,26 +3,25 @@ title: dcode
3
  emoji: ✏️
4
  colorFrom: gray
5
  colorTo: green
6
- sdk: static
 
 
7
  pinned: false
8
  license: mit
9
- short_description: Text to Polargraph Gcode via Diffusion
10
  ---
11
 
12
  # dcode
13
 
14
- Generate polargraph-compatible gcode from text prompts.
15
 
16
  ## Usage
17
 
18
  1. Enter a prompt (e.g., "drawing of a cat")
19
- 2. Click Generate
20
- 3. View result in workplane (zoom/pan with mouse)
21
- 4. Download as Gcode or SVG
22
 
23
- ## Features
24
 
25
- - Real-time gcode visualization
26
- - Zoom/pan controls
27
- - Machine compatibility validation
28
- - Export to Gcode or SVG
 
3
  emoji: ✏️
4
  colorFrom: gray
5
  colorTo: green
6
+ sdk: gradio
7
+ sdk_version: 4.44.0
8
+ app_file: app.py
9
  pinned: false
10
  license: mit
11
+ short_description: Text to Polargraph Gcode
12
  ---
13
 
14
  # dcode
15
 
16
+ Generate polargraph-compatible gcode from text prompts using finetuned diffusion models.
17
 
18
  ## Usage
19
 
20
  1. Enter a prompt (e.g., "drawing of a cat")
21
+ 2. Adjust temperature (higher = more creative)
22
+ 3. Click Generate
23
+ 4. View preview and download gcode
24
 
25
+ ## Model
26
 
27
+ Finetuned Flan-T5-base on 175k image-gcode pairs.
 
 
 
app.js DELETED
@@ -1,251 +0,0 @@
1
- // dcode - Gcode visualization and generation
2
-
3
- class GcodeViewer {
4
- constructor(canvas) {
5
- this.canvas = canvas;
6
- this.ctx = canvas.getContext('2d');
7
- this.paths = [];
8
- this.zoom = 1;
9
- this.panX = 0;
10
- this.panY = 0;
11
- this.isDragging = false;
12
- this.lastMouse = { x: 0, y: 0 };
13
-
14
- // Work area bounds (from machine config)
15
- this.bounds = {
16
- left: -420.5,
17
- right: 420.5,
18
- top: 594.5,
19
- bottom: -594.5
20
- };
21
-
22
- this.resize();
23
- this.setupEvents();
24
- this.draw();
25
- }
26
-
27
- resize() {
28
- const rect = this.canvas.getBoundingClientRect();
29
- this.canvas.width = rect.width * window.devicePixelRatio;
30
- this.canvas.height = rect.height * window.devicePixelRatio;
31
- this.ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
32
- }
33
-
34
- setupEvents() {
35
- this.canvas.addEventListener('wheel', (e) => {
36
- e.preventDefault();
37
- const delta = e.deltaY > 0 ? 0.9 : 1.1;
38
- this.zoom *= delta;
39
- this.zoom = Math.max(0.1, Math.min(10, this.zoom));
40
- this.draw();
41
- });
42
-
43
- this.canvas.addEventListener('mousedown', (e) => {
44
- this.isDragging = true;
45
- this.lastMouse = { x: e.clientX, y: e.clientY };
46
- });
47
-
48
- this.canvas.addEventListener('mousemove', (e) => {
49
- if (this.isDragging) {
50
- this.panX += e.clientX - this.lastMouse.x;
51
- this.panY += e.clientY - this.lastMouse.y;
52
- this.lastMouse = { x: e.clientX, y: e.clientY };
53
- this.draw();
54
- }
55
- });
56
-
57
- this.canvas.addEventListener('mouseup', () => this.isDragging = false);
58
- this.canvas.addEventListener('mouseleave', () => this.isDragging = false);
59
-
60
- window.addEventListener('resize', () => {
61
- this.resize();
62
- this.draw();
63
- });
64
- }
65
-
66
- parseGcode(gcode) {
67
- this.paths = [];
68
- let currentPath = [];
69
- let x = 0, y = 0;
70
- let penDown = false;
71
-
72
- const lines = gcode.split('\n');
73
- for (const line of lines) {
74
- const trimmed = line.trim();
75
- if (!trimmed || trimmed.startsWith(';')) continue;
76
-
77
- // Pen up/down
78
- if (trimmed.includes('M280')) {
79
- const match = trimmed.match(/S(\d+)/);
80
- if (match) {
81
- const angle = parseInt(match[1]);
82
- const wasDown = penDown;
83
- penDown = angle < 50; // Down if angle < 50
84
-
85
- if (wasDown && !penDown && currentPath.length > 1) {
86
- this.paths.push([...currentPath]);
87
- currentPath = [];
88
- }
89
- }
90
- }
91
-
92
- // Movement
93
- const xMatch = trimmed.match(/X([-\d.]+)/i);
94
- const yMatch = trimmed.match(/Y([-\d.]+)/i);
95
-
96
- if (xMatch) x = parseFloat(xMatch[1]);
97
- if (yMatch) y = parseFloat(yMatch[1]);
98
-
99
- if ((xMatch || yMatch) && penDown) {
100
- currentPath.push({ x, y });
101
- }
102
- }
103
-
104
- if (currentPath.length > 1) {
105
- this.paths.push(currentPath);
106
- }
107
-
108
- this.resetView();
109
- this.draw();
110
- }
111
-
112
- resetView() {
113
- this.zoom = 1;
114
- this.panX = 0;
115
- this.panY = 0;
116
- }
117
-
118
- draw() {
119
- const w = this.canvas.width / window.devicePixelRatio;
120
- const h = this.canvas.height / window.devicePixelRatio;
121
-
122
- this.ctx.fillStyle = '#111';
123
- this.ctx.fillRect(0, 0, w, h);
124
-
125
- this.ctx.save();
126
- this.ctx.translate(w / 2 + this.panX, h / 2 + this.panY);
127
-
128
- // Scale to fit work area
129
- const boundsW = this.bounds.right - this.bounds.left;
130
- const boundsH = this.bounds.top - this.bounds.bottom;
131
- const scale = Math.min(w / boundsW, h / boundsH) * 0.9 * this.zoom;
132
-
133
- this.ctx.scale(scale, -scale); // Flip Y
134
-
135
- // Draw work area
136
- this.ctx.strokeStyle = '#333';
137
- this.ctx.lineWidth = 1 / scale;
138
- this.ctx.strokeRect(
139
- this.bounds.left,
140
- this.bounds.bottom,
141
- boundsW,
142
- boundsH
143
- );
144
-
145
- // Draw paths
146
- this.ctx.strokeStyle = '#4ade80';
147
- this.ctx.lineWidth = 1 / scale;
148
- this.ctx.lineCap = 'round';
149
- this.ctx.lineJoin = 'round';
150
-
151
- for (const path of this.paths) {
152
- if (path.length < 2) continue;
153
- this.ctx.beginPath();
154
- this.ctx.moveTo(path[0].x, path[0].y);
155
- for (let i = 1; i < path.length; i++) {
156
- this.ctx.lineTo(path[i].x, path[i].y);
157
- }
158
- this.ctx.stroke();
159
- }
160
-
161
- this.ctx.restore();
162
- }
163
-
164
- toSVG() {
165
- const boundsW = this.bounds.right - this.bounds.left;
166
- const boundsH = this.bounds.top - this.bounds.bottom;
167
-
168
- let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${this.bounds.left} ${-this.bounds.top} ${boundsW} ${boundsH}">`;
169
- svg += `<rect x="${this.bounds.left}" y="${-this.bounds.top}" width="${boundsW}" height="${boundsH}" fill="none" stroke="#333"/>`;
170
-
171
- for (const path of this.paths) {
172
- if (path.length < 2) continue;
173
- const d = path.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${-p.y}`).join(' ');
174
- svg += `<path d="${d}" fill="none" stroke="#4ade80" stroke-width="1"/>`;
175
- }
176
-
177
- svg += '</svg>';
178
- return svg;
179
- }
180
- }
181
-
182
- // Main app
183
- let viewer;
184
- let currentGcode = '';
185
-
186
- document.addEventListener('DOMContentLoaded', () => {
187
- const canvas = document.getElementById('workplane');
188
- viewer = new GcodeViewer(canvas);
189
-
190
- document.getElementById('zoom-in').onclick = () => {
191
- viewer.zoom *= 1.2;
192
- viewer.draw();
193
- };
194
-
195
- document.getElementById('zoom-out').onclick = () => {
196
- viewer.zoom *= 0.8;
197
- viewer.draw();
198
- };
199
-
200
- document.getElementById('reset-view').onclick = () => {
201
- viewer.resetView();
202
- viewer.draw();
203
- };
204
-
205
- document.getElementById('generate').onclick = async () => {
206
- const prompt = document.getElementById('prompt').value;
207
- if (!prompt) return;
208
-
209
- document.getElementById('status').textContent = 'Generating...';
210
- document.getElementById('validation').textContent = '';
211
-
212
- // TODO: Call actual inference API
213
- // For now, show placeholder
214
- document.getElementById('status').textContent = 'API endpoint not configured. Deploy with Gradio backend.';
215
- };
216
-
217
- document.getElementById('download-gcode').onclick = () => {
218
- if (!currentGcode) return;
219
- const blob = new Blob([currentGcode], { type: 'text/plain' });
220
- const url = URL.createObjectURL(blob);
221
- const a = document.createElement('a');
222
- a.href = url;
223
- a.download = 'output.gcode';
224
- a.click();
225
- URL.revokeObjectURL(url);
226
- };
227
-
228
- document.getElementById('download-svg').onclick = () => {
229
- if (!viewer.paths.length) return;
230
- const svg = viewer.toSVG();
231
- const blob = new Blob([svg], { type: 'image/svg+xml' });
232
- const url = URL.createObjectURL(blob);
233
- const a = document.createElement('a');
234
- a.href = url;
235
- a.download = 'output.svg';
236
- a.click();
237
- URL.revokeObjectURL(url);
238
- };
239
- });
240
-
241
- // Expose for testing
242
- window.loadGcode = (gcode) => {
243
- currentGcode = gcode;
244
- viewer.parseGcode(gcode);
245
- document.getElementById('download-gcode').disabled = false;
246
- document.getElementById('download-svg').disabled = false;
247
- document.getElementById('status').textContent = `Loaded ${viewer.paths.length} paths`;
248
- document.getElementById('validation').textContent = 'Machine compatible';
249
- document.getElementById('validation').className = 'valid';
250
- };
251
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """dcode Gradio Space - Text to Gcode inference."""
2
+
3
+ import re
4
+ import gradio as gr
5
+ import torch
6
+ from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
7
+
8
+ # Model config - update after training
9
+ MODEL_ID = "twarner/dcode-flan-t5-base" # Will upload after training
10
+
11
+ # Machine limits
12
+ BOUNDS = {"left": -420.5, "right": 420.5, "top": 594.5, "bottom": -594.5}
13
+ PEN = {"up": 90, "down": 40, "travel": 1000, "draw": 500}
14
+
15
+
16
+ class GcodeGenerator:
17
+ def __init__(self):
18
+ self.model = None
19
+ self.tokenizer = None
20
+ self.device = "cuda" if torch.cuda.is_available() else "cpu"
21
+
22
+ def load(self):
23
+ if self.model is None:
24
+ self.tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
25
+ self.model = AutoModelForSeq2SeqLM.from_pretrained(
26
+ MODEL_ID, torch_dtype=torch.float16 if self.device == "cuda" else torch.float32
27
+ ).to(self.device)
28
+ self.model.eval()
29
+
30
+ def generate(self, prompt: str, max_length: int = 1024, temperature: float = 0.8) -> str:
31
+ self.load()
32
+
33
+ inputs = self.tokenizer(prompt, return_tensors="pt", max_length=128, truncation=True)
34
+ inputs = {k: v.to(self.device) for k, v in inputs.items()}
35
+
36
+ with torch.no_grad():
37
+ outputs = self.model.generate(
38
+ **inputs,
39
+ max_new_tokens=max_length,
40
+ do_sample=True,
41
+ temperature=temperature,
42
+ top_p=0.9,
43
+ )
44
+
45
+ gcode = self.tokenizer.decode(outputs[0], skip_special_tokens=True)
46
+ return self.validate(gcode)
47
+
48
+ def validate(self, gcode: str) -> str:
49
+ """Clamp coordinates to machine bounds."""
50
+ lines = []
51
+ for line in gcode.split("\n"):
52
+ corrected = line
53
+
54
+ # Clamp X
55
+ x_match = re.search(r"X([-\d.]+)", line, re.IGNORECASE)
56
+ if x_match:
57
+ x = float(x_match.group(1))
58
+ x = max(BOUNDS["left"], min(BOUNDS["right"], x))
59
+ corrected = re.sub(r"X[-\d.]+", f"X{x:.2f}", corrected, flags=re.IGNORECASE)
60
+
61
+ # Clamp Y
62
+ y_match = re.search(r"Y([-\d.]+)", line, re.IGNORECASE)
63
+ if y_match:
64
+ y = float(y_match.group(1))
65
+ y = max(BOUNDS["bottom"], min(BOUNDS["top"], y))
66
+ corrected = re.sub(r"Y[-\d.]+", f"Y{y:.2f}", corrected, flags=re.IGNORECASE)
67
+
68
+ lines.append(corrected)
69
+
70
+ return "\n".join(lines)
71
+
72
+
73
+ def gcode_to_svg(gcode: str) -> str:
74
+ """Convert gcode to SVG for preview."""
75
+ paths = []
76
+ current_path = []
77
+ x, y = 0, 0
78
+ pen_down = False
79
+
80
+ for line in gcode.split("\n"):
81
+ line = line.strip()
82
+ if not line or line.startswith(";"):
83
+ continue
84
+
85
+ # Pen state
86
+ if "M280" in line:
87
+ match = re.search(r"S(\d+)", line)
88
+ if match:
89
+ angle = int(match.group(1))
90
+ was_down = pen_down
91
+ pen_down = angle < 50
92
+ if was_down and not pen_down and len(current_path) > 1:
93
+ paths.append(current_path[:])
94
+ current_path = []
95
+
96
+ # Position
97
+ x_match = re.search(r"X([-\d.]+)", line, re.IGNORECASE)
98
+ y_match = re.search(r"Y([-\d.]+)", line, re.IGNORECASE)
99
+ if x_match:
100
+ x = float(x_match.group(1))
101
+ if y_match:
102
+ y = float(y_match.group(1))
103
+
104
+ if (x_match or y_match) and pen_down:
105
+ current_path.append((x, y))
106
+
107
+ if len(current_path) > 1:
108
+ paths.append(current_path)
109
+
110
+ # Build SVG
111
+ w = BOUNDS["right"] - BOUNDS["left"]
112
+ h = BOUNDS["top"] - BOUNDS["bottom"]
113
+
114
+ svg = f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{BOUNDS["left"]} {-BOUNDS["top"]} {w} {h}" style="background:#111">'
115
+ svg += f'<rect x="{BOUNDS["left"]}" y="{-BOUNDS["top"]}" width="{w}" height="{h}" fill="none" stroke="#333" stroke-width="2"/>'
116
+
117
+ for path in paths:
118
+ if len(path) < 2:
119
+ continue
120
+ d = " ".join(f"{'M' if i == 0 else 'L'}{p[0]:.1f},{-p[1]:.1f}" for i, p in enumerate(path))
121
+ svg += f'<path d="{d}" fill="none" stroke="#4ade80" stroke-width="1.5" stroke-linecap="round"/>'
122
+
123
+ svg += "</svg>"
124
+ return svg
125
+
126
+
127
+ # Initialize generator
128
+ generator = GcodeGenerator()
129
+
130
+
131
+ def generate(prompt: str, temperature: float) -> tuple[str, str, str]:
132
+ """Generate gcode from prompt."""
133
+ if not prompt.strip():
134
+ return "", "", "Enter a prompt"
135
+
136
+ try:
137
+ gcode = generator.generate(prompt, temperature=temperature)
138
+ svg = gcode_to_svg(gcode)
139
+ status = f"✓ Generated {len(gcode.split(chr(10)))} lines, machine compatible"
140
+ return gcode, svg, status
141
+ except Exception as e:
142
+ return "", "", f"Error: {e}"
143
+
144
+
145
+ # Custom CSS
146
+ custom_css = """
147
+ #workplane {
148
+ background: #111;
149
+ border-radius: 8px;
150
+ min-height: 500px;
151
+ }
152
+ #workplane svg {
153
+ width: 100%;
154
+ height: 100%;
155
+ }
156
+ .status {
157
+ font-family: monospace;
158
+ }
159
+ """
160
+
161
+ # Gradio UI
162
+ with gr.Blocks(css=custom_css, theme=gr.themes.Base(primary_hue="green")) as demo:
163
+ gr.Markdown("# dcode\nText prompt → Polargraph Gcode")
164
+
165
+ with gr.Row():
166
+ with gr.Column(scale=2):
167
+ prompt = gr.Textbox(label="Prompt", placeholder="drawing of a cat...", lines=1)
168
+ temperature = gr.Slider(0.1, 1.5, value=0.8, label="Temperature")
169
+ generate_btn = gr.Button("Generate", variant="primary")
170
+
171
+ with gr.Column(scale=1):
172
+ status = gr.Textbox(label="Status", interactive=False, elem_classes=["status"])
173
+
174
+ with gr.Row():
175
+ preview = gr.HTML(elem_id="workplane")
176
+
177
+ with gr.Row():
178
+ gcode_output = gr.Textbox(label="Gcode", lines=10, show_copy_button=True)
179
+
180
+ with gr.Row():
181
+ gr.DownloadButton("Download Gcode", value=lambda: None, visible=False)
182
+
183
+ generate_btn.click(generate, [prompt, temperature], [gcode_output, preview, status])
184
+ prompt.submit(generate, [prompt, temperature], [gcode_output, preview, status])
185
+
186
+
187
+ if __name__ == "__main__":
188
+ demo.launch()
189
+
index.html DELETED
@@ -1,43 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>dcode - Text to Gcode</title>
7
- <link rel="stylesheet" href="style.css">
8
- </head>
9
- <body>
10
- <div class="container">
11
- <header>
12
- <h1>dcode</h1>
13
- <p>Text prompt → Polargraph Gcode</p>
14
- </header>
15
-
16
- <div class="input-section">
17
- <input type="text" id="prompt" placeholder="drawing of a cat..." />
18
- <button id="generate">Generate</button>
19
- </div>
20
-
21
- <div class="workplane-container">
22
- <canvas id="workplane"></canvas>
23
- <div class="controls">
24
- <button id="zoom-in">+</button>
25
- <button id="zoom-out">-</button>
26
- <button id="reset-view">Reset</button>
27
- </div>
28
- </div>
29
-
30
- <div class="status-section">
31
- <div id="status"></div>
32
- <div id="validation"></div>
33
- </div>
34
-
35
- <div class="download-section">
36
- <button id="download-gcode" disabled>Download Gcode</button>
37
- <button id="download-svg" disabled>Download SVG</button>
38
- </div>
39
- </div>
40
-
41
- <script src="app.js"></script>
42
- </body>
43
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ gradio>=4.0
2
+ torch>=2.0
3
+ transformers>=4.36
4
+ accelerate>=0.25
5
+
style.css DELETED
@@ -1,130 +0,0 @@
1
- * {
2
- box-sizing: border-box;
3
- margin: 0;
4
- padding: 0;
5
- }
6
-
7
- body {
8
- font-family: system-ui, sans-serif;
9
- background: #1a1a1a;
10
- color: #e0e0e0;
11
- min-height: 100vh;
12
- }
13
-
14
- .container {
15
- max-width: 900px;
16
- margin: 0 auto;
17
- padding: 2rem;
18
- }
19
-
20
- header {
21
- text-align: center;
22
- margin-bottom: 2rem;
23
- }
24
-
25
- header h1 {
26
- font-size: 2rem;
27
- margin-bottom: 0.5rem;
28
- }
29
-
30
- header p {
31
- color: #888;
32
- }
33
-
34
- .input-section {
35
- display: flex;
36
- gap: 1rem;
37
- margin-bottom: 2rem;
38
- }
39
-
40
- #prompt {
41
- flex: 1;
42
- padding: 1rem;
43
- font-size: 1rem;
44
- border: 1px solid #333;
45
- border-radius: 4px;
46
- background: #222;
47
- color: #e0e0e0;
48
- }
49
-
50
- button {
51
- padding: 1rem 2rem;
52
- font-size: 1rem;
53
- border: none;
54
- border-radius: 4px;
55
- background: #444;
56
- color: #e0e0e0;
57
- cursor: pointer;
58
- }
59
-
60
- button:hover {
61
- background: #555;
62
- }
63
-
64
- button:disabled {
65
- opacity: 0.5;
66
- cursor: not-allowed;
67
- }
68
-
69
- #generate {
70
- background: #2563eb;
71
- }
72
-
73
- #generate:hover {
74
- background: #1d4ed8;
75
- }
76
-
77
- .workplane-container {
78
- position: relative;
79
- background: #111;
80
- border: 1px solid #333;
81
- border-radius: 4px;
82
- margin-bottom: 1rem;
83
- }
84
-
85
- #workplane {
86
- width: 100%;
87
- height: 500px;
88
- display: block;
89
- }
90
-
91
- .controls {
92
- position: absolute;
93
- top: 1rem;
94
- right: 1rem;
95
- display: flex;
96
- gap: 0.5rem;
97
- }
98
-
99
- .controls button {
100
- padding: 0.5rem 1rem;
101
- font-size: 1.2rem;
102
- }
103
-
104
- .status-section {
105
- margin-bottom: 1rem;
106
- padding: 1rem;
107
- background: #222;
108
- border-radius: 4px;
109
- }
110
-
111
- #status {
112
- margin-bottom: 0.5rem;
113
- }
114
-
115
- #validation.valid {
116
- color: #22c55e;
117
- }
118
-
119
- #validation.invalid {
120
- color: #ef4444;
121
- }
122
-
123
- .download-section {
124
- display: flex;
125
- gap: 1rem;
126
- }
127
-
128
- .download-section button {
129
- flex: 1;
130
- }