create-hedgeboard 1.0.7 → 1.1.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.
@@ -1,330 +0,0 @@
1
- """Dashboard renderer.
2
-
3
- Generates full HTML pages with branded styling, Chart.js charts,
4
- and financial tables. Saves to output/ and serves on localhost.
5
-
6
- Usage:
7
- from viz.dashboard import render_dashboard
8
-
9
- render_dashboard(
10
- title="Twilio Company Overview",
11
- sections=[chart_html, table_html, "## Analysis\nKey insight here."],
12
- output_name="twilio_overview"
13
- )
14
- """
15
-
16
- from __future__ import annotations
17
-
18
- import http.server
19
- import json
20
- import os
21
- import re
22
- import threading
23
- import webbrowser
24
- from datetime import datetime
25
- from pathlib import Path
26
-
27
- _ROOT = Path(__file__).resolve().parent.parent
28
- _THEME_PATH = _ROOT / "brand" / "theme.json"
29
- _CSS_PATH = _ROOT / "brand" / "base.css"
30
- _OUTPUT_DIR = _ROOT / "output"
31
-
32
-
33
- def _load_theme() -> dict:
34
- try:
35
- with open(_THEME_PATH) as f:
36
- return json.load(f)
37
- except (FileNotFoundError, json.JSONDecodeError):
38
- return {
39
- "name": "HedgeBoard",
40
- "colors": {
41
- "primary": "#EAEAEA",
42
- "accent": "#CACACA",
43
- "positive": "#4ADE80",
44
- "negative": "#F87171",
45
- "background": "#0C0C10",
46
- "surface": "#151519",
47
- "text": "#EAEAEA",
48
- "text_secondary": "#B0B0B8",
49
- "muted": "#6B6B78",
50
- "border": "#2A2A33",
51
- },
52
- "font": {"family": "Space Grotesk", "headings": "Space Grotesk", "mono": "IBM Plex Mono"},
53
- "mode": "dark",
54
- }
55
-
56
-
57
- def _load_css() -> str:
58
- try:
59
- return _CSS_PATH.read_text()
60
- except FileNotFoundError:
61
- return ""
62
-
63
-
64
- def _markdown_to_html(text: str) -> str:
65
- """Minimal markdown conversion for analysis sections."""
66
- # Headers
67
- text = re.sub(r"^### (.+)$", r"<h3>\1</h3>", text, flags=re.MULTILINE)
68
- text = re.sub(r"^## (.+)$", r"<h2>\1</h2>", text, flags=re.MULTILINE)
69
- text = re.sub(r"^# (.+)$", r"<h1>\1</h1>", text, flags=re.MULTILINE)
70
- # Bold and italic
71
- text = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", text)
72
- text = re.sub(r"\*(.+?)\*", r"<em>\1</em>", text)
73
- # Line breaks → paragraphs
74
- paragraphs = text.split("\n\n")
75
- result = []
76
- for p in paragraphs:
77
- p = p.strip()
78
- if not p:
79
- continue
80
- if p.startswith("<h") or p.startswith("<div") or p.startswith("<table"):
81
- result.append(p)
82
- else:
83
- result.append(f"<p>{p}</p>")
84
- return "\n".join(result)
85
-
86
-
87
- def render_dashboard(
88
- title: str,
89
- sections: list[str],
90
- output_name: str | None = None,
91
- serve: bool = True,
92
- port: int = 4747,
93
- ) -> Path:
94
- """Render a full branded dashboard page.
95
-
96
- Args:
97
- title: Dashboard title.
98
- sections: List of HTML snippets or markdown text. Each becomes
99
- a section in the dashboard.
100
- output_name: Folder name under output/. Auto-generated if None.
101
- serve: If True, start a local HTTP server and open in browser.
102
- port: Port for the local server.
103
-
104
- Returns:
105
- Path to the generated index.html.
106
- """
107
- theme = _load_theme()
108
- colors = theme.get("colors", {})
109
- font = theme.get("font", {})
110
- brand_name = theme.get("name", "HedgeBoard")
111
- css = _load_css()
112
-
113
- # Generate output folder name
114
- if not output_name:
115
- slug = re.sub(r"[^a-z0-9]+", "_", title.lower()).strip("_")
116
- output_name = f"{slug}_{datetime.now().strftime('%Y%m%d_%H%M')}"
117
-
118
- output_dir = _OUTPUT_DIR / output_name
119
- output_dir.mkdir(parents=True, exist_ok=True)
120
-
121
- # Logo
122
- logo_path = theme.get("logo", "")
123
- logo_html = ""
124
- if logo_path:
125
- abs_logo = _ROOT / logo_path
126
- if abs_logo.exists():
127
- logo_html = f'<img src="../../{logo_path}" alt="{brand_name}" style="height:32px;margin-right:12px;">'
128
-
129
- # Build sections HTML
130
- sections_html = []
131
- for section in sections:
132
- if section.strip().startswith("<"):
133
- # Already HTML (chart or table)
134
- sections_html.append(
135
- f'<div class="section">{section}</div>'
136
- )
137
- else:
138
- # Markdown text
139
- sections_html.append(
140
- f'<div class="section text-section">{_markdown_to_html(section)}</div>'
141
- )
142
-
143
- font_family = font.get("family", "Space Grotesk")
144
- heading_font = font.get("headings", font_family)
145
- mono_font = font.get("mono", "IBM Plex Mono")
146
-
147
- html = f"""<!DOCTYPE html>
148
- <html lang="en">
149
- <head>
150
- <meta charset="UTF-8">
151
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
152
- <title>{title} — {brand_name}</title>
153
- <link href="https://fonts.googleapis.com/css2?family={font_family.replace(' ', '+')}:wght@400;500;600;700&family={heading_font.replace(' ', '+')}:wght@600;700&family={mono_font.replace(' ', '+')}:wght@400;500;600&display=swap" rel="stylesheet">
154
- <script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
155
- <style>
156
- :root {{
157
- --color-primary: {colors.get('primary', '#EAEAEA')};
158
- --color-accent: {colors.get('accent', '#CACACA')};
159
- --color-positive: {colors.get('positive', '#4ADE80')};
160
- --color-negative: {colors.get('negative', '#F87171')};
161
- --color-bg: {colors.get('background', '#0C0C10')};
162
- --color-surface: {colors.get('surface', '#151519')};
163
- --color-text: {colors.get('text', '#EAEAEA')};
164
- --color-muted: {colors.get('muted', '#6B6B78')};
165
- --color-border: {colors.get('border', '#2A2A33')};
166
- --font-body: '{font_family}', system-ui, sans-serif;
167
- --font-heading: '{heading_font}', system-ui, sans-serif;
168
- --font-mono: '{mono_font}', monospace;
169
- }}
170
-
171
- * {{ margin: 0; padding: 0; box-sizing: border-box; }}
172
-
173
- body {{
174
- background: var(--color-bg);
175
- color: var(--color-text);
176
- font-family: var(--font-body);
177
- line-height: 1.6;
178
- min-height: 100vh;
179
- }}
180
-
181
- .header {{
182
- display: flex;
183
- align-items: center;
184
- padding: 20px 40px;
185
- border-bottom: 1px solid var(--color-surface);
186
- }}
187
-
188
- .header h1 {{
189
- font-family: var(--font-heading);
190
- font-size: 20px;
191
- font-weight: 700;
192
- color: var(--color-text);
193
- }}
194
-
195
- .header .brand {{
196
- margin-left: auto;
197
- font-family: var(--font-mono);
198
- font-size: 11px;
199
- color: var(--color-muted);
200
- text-transform: uppercase;
201
- letter-spacing: 1.5px;
202
- }}
203
-
204
- .container {{
205
- max-width: 1200px;
206
- margin: 0 auto;
207
- padding: 32px 40px;
208
- }}
209
-
210
- .section {{
211
- margin-bottom: 24px;
212
- }}
213
-
214
- .text-section h2 {{
215
- font-family: var(--font-heading);
216
- font-size: 20px;
217
- font-weight: 700;
218
- color: var(--color-text);
219
- margin-bottom: 12px;
220
- margin-top: 24px;
221
- }}
222
-
223
- .text-section h3 {{
224
- font-family: var(--font-heading);
225
- font-size: 16px;
226
- font-weight: 600;
227
- color: var(--color-text);
228
- margin-bottom: 8px;
229
- margin-top: 16px;
230
- }}
231
-
232
- .text-section p {{
233
- color: var(--color-muted);
234
- font-size: 14px;
235
- margin-bottom: 12px;
236
- line-height: 1.7;
237
- }}
238
-
239
- .text-section strong {{
240
- color: var(--color-text);
241
- }}
242
-
243
- .footer {{
244
- padding: 20px 40px;
245
- border-top: 1px solid var(--color-surface);
246
- text-align: center;
247
- font-family: var(--font-mono);
248
- font-size: 11px;
249
- color: var(--color-muted);
250
- }}
251
-
252
- {css}
253
- </style>
254
- </head>
255
- <body>
256
- <div class="header">
257
- {logo_html}
258
- <h1>{title}</h1>
259
- <div class="brand">{brand_name}</div>
260
- </div>
261
- <div class="container">
262
- {''.join(sections_html)}
263
- </div>
264
- <div class="footer">
265
- Generated {datetime.now().strftime('%b %d, %Y at %H:%M')} &middot; {brand_name}
266
- </div>
267
- </body>
268
- </html>"""
269
-
270
- # Write output
271
- index_path = output_dir / "index.html"
272
- index_path.write_text(html)
273
-
274
- print(f"📊 Dashboard saved to {index_path}")
275
-
276
- if serve:
277
- _serve_and_open(output_dir, index_path, port)
278
-
279
- return index_path
280
-
281
-
282
- def _serve_and_open(directory: Path, index_path: Path, port: int) -> None:
283
- """Start a local HTTP server and open the dashboard in a browser.
284
-
285
- The server runs on the main thread so it stays alive until the user
286
- presses Ctrl+C. The browser is opened from a background thread after
287
- a short delay so it doesn't race the server startup.
288
- """
289
-
290
- class QuietHandler(http.server.SimpleHTTPRequestHandler):
291
- def __init__(self, *args, **kwargs):
292
- super().__init__(*args, directory=str(directory), **kwargs)
293
-
294
- def log_message(self, format, *args):
295
- pass # Suppress logs
296
-
297
- # Find an available port
298
- server = None
299
- for p in (port, port + 1):
300
- try:
301
- server = http.server.HTTPServer(("", p), QuietHandler)
302
- port = p
303
- break
304
- except OSError:
305
- continue
306
-
307
- if server is None:
308
- print("⚠️ Could not start HTTP server, opening file directly")
309
- webbrowser.open(f"file://{index_path.resolve()}")
310
- return
311
-
312
- server_url = f"http://localhost:{port}"
313
- print(f"🌐 Serving at {server_url}")
314
- print(" Press Ctrl+C to stop the server.")
315
-
316
- # Open browser from a background thread (small delay so server is ready)
317
- def _open():
318
- import time
319
- time.sleep(0.4)
320
- webbrowser.open(server_url)
321
-
322
- threading.Thread(target=_open, daemon=True).start()
323
-
324
- # Block the main thread — keeps the server alive
325
- try:
326
- server.serve_forever()
327
- except KeyboardInterrupt:
328
- print("\n🛑 Server stopped.")
329
- server.shutdown()
330
-
@@ -1,140 +0,0 @@
1
- """Financial table generation.
2
-
3
- Generates styled HTML tables with number formatting, branded colors,
4
- and support for positive/negative value highlighting.
5
-
6
- Usage:
7
- from viz.tables import financial_table
8
-
9
- html = financial_table(
10
- headers=["Metric", "2023", "2024"],
11
- rows=[
12
- ["Revenue", 3826000000, 4150000000],
13
- ["Net Income", -398000000, 120000000],
14
- ]
15
- )
16
- """
17
-
18
- from __future__ import annotations
19
-
20
- import json
21
- from pathlib import Path
22
-
23
- _THEME_PATH = Path(__file__).resolve().parent.parent / "brand" / "theme.json"
24
-
25
-
26
- def _load_theme() -> dict:
27
- try:
28
- with open(_THEME_PATH) as f:
29
- return json.load(f)
30
- except (FileNotFoundError, json.JSONDecodeError):
31
- return {
32
- "colors": {
33
- "primary": "#6366F1",
34
- "accent": "#22D3EE",
35
- "background": "#0D1117",
36
- "surface": "#161B22",
37
- "text": "#E6EDF3",
38
- "muted": "#8B949E",
39
- }
40
- }
41
-
42
-
43
- def _format_number(val) -> str:
44
- """Format a number for financial display."""
45
- if val is None:
46
- return "—"
47
- if isinstance(val, str):
48
- return val
49
-
50
- num = float(val)
51
- abs_num = abs(num)
52
-
53
- if abs_num >= 1_000_000_000:
54
- formatted = f"${abs_num / 1_000_000_000:.1f}B"
55
- elif abs_num >= 1_000_000:
56
- formatted = f"${abs_num / 1_000_000:.1f}M"
57
- elif abs_num >= 1_000:
58
- formatted = f"${abs_num / 1_000:.1f}K"
59
- else:
60
- formatted = f"${abs_num:,.2f}"
61
-
62
- if num < 0:
63
- return f"({formatted})"
64
- return formatted
65
-
66
-
67
- def financial_table(
68
- headers: list[str],
69
- rows: list[list],
70
- title: str = "",
71
- format_numbers: bool = True,
72
- ) -> str:
73
- """Generate a styled financial table.
74
-
75
- Args:
76
- headers: Column headers.
77
- rows: List of rows, each a list of values. First column is
78
- typically the metric name (string), rest are numbers.
79
- title: Optional table title.
80
- format_numbers: Auto-format numbers (B/M/K, negative in parens).
81
-
82
- Returns:
83
- HTML string with a styled table.
84
- """
85
- theme = _load_theme()
86
- colors = theme.get("colors", {})
87
-
88
- bg = colors.get("surface", "#161B22")
89
- border = colors.get("background", "#0D1117")
90
- text = colors.get("text", "#E6EDF3")
91
- muted = colors.get("muted", "#8B949E")
92
- accent = colors.get("accent", "#22D3EE")
93
-
94
- # Build header row
95
- header_cells = "".join(
96
- f'<th style="padding:12px 16px;text-align:{"left" if i == 0 else "right"};'
97
- f'color:{accent};font-weight:600;border-bottom:2px solid {border};'
98
- f'font-size:13px;text-transform:uppercase;letter-spacing:0.5px;">'
99
- f"{h}</th>"
100
- for i, h in enumerate(headers)
101
- )
102
-
103
- # Build body rows
104
- body_rows = []
105
- for row in rows:
106
- cells = []
107
- for i, val in enumerate(row):
108
- if i == 0:
109
- # Metric name column
110
- cell = (
111
- f'<td style="padding:10px 16px;color:{text};font-weight:500;'
112
- f'border-bottom:1px solid {border};">{val}</td>'
113
- )
114
- else:
115
- # Value column
116
- display = _format_number(val) if format_numbers else str(val)
117
- is_negative = isinstance(val, (int, float)) and val < 0
118
- val_color = "#EF4444" if is_negative else text
119
- cell = (
120
- f'<td style="padding:10px 16px;text-align:right;color:{val_color};'
121
- f'font-variant-numeric:tabular-nums;border-bottom:1px solid {border};'
122
- f'font-family:monospace;">{display}</td>'
123
- )
124
- cells.append(cell)
125
- body_rows.append(f"<tr>{''.join(cells)}</tr>")
126
-
127
- title_html = ""
128
- if title:
129
- title_html = (
130
- f'<div style="padding:16px 16px 0;color:{text};font-size:16px;'
131
- f'font-weight:600;">{title}</div>'
132
- )
133
-
134
- return f"""{title_html}
135
- <div style="background:{bg};border-radius:12px;overflow:hidden;">
136
- <table style="width:100%;border-collapse:collapse;font-size:14px;">
137
- <thead><tr>{header_cells}</tr></thead>
138
- <tbody>{''.join(body_rows)}</tbody>
139
- </table>
140
- </div>"""