Spaces:
Sleeping
Sleeping
| """ | |
| Budget Advisor β a personal financial-planning tool. | |
| Visual direction: editorial private-wealth report. Warm off-white paper, | |
| serif headlines, monospace figures, no emoji. All styling is delivered | |
| through a custom Gradio CSS override plus Google Fonts (Source Serif 4, | |
| Inter, JetBrains Mono). The Gradio theme is kept intentionally neutral | |
| so the custom CSS fully controls the look. | |
| """ | |
| import gradio as gr | |
| import json | |
| import tempfile | |
| from datetime import datetime | |
| # ββ constants ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| EXPENSE_CATS = [ | |
| "Housing", "Food & groceries", "Transport", "Utilities", | |
| "Entertainment", "Health", "Education", "Clothing", "Other", | |
| ] | |
| CAT_LIMITS = { | |
| "Housing": 30, "Food & groceries": 15, "Transport": 15, | |
| "Utilities": 10, "Entertainment": 10, "Health": 10, | |
| "Education": 10, "Clothing": 5, "Other": 10, | |
| } | |
| # Neutral palette of swatches for the stacked-bar + swatch column. | |
| CAT_SWATCH = { | |
| "Housing": "#1c1a17", | |
| "Food & groceries": "#3d3932", | |
| "Transport": "#5e5849", | |
| "Utilities": "#7a7462", | |
| "Entertainment": "#958f7e", | |
| "Health": "#a8a28f", | |
| "Education": "#b6b09d", | |
| "Clothing": "#c4bead", | |
| "Other": "#d3cdbb", | |
| } | |
| FOLLOW_UP_PROMPT = """You are a practical, direct personal finance advisor. I am going to give you a JSON object that summarizes my income, pre-tax deductions, retirement contribution, and monthly expenses. Please do the following: | |
| 1. Open with a two- to three-sentence read on the overall health of this budget β what is working, what is concerning. | |
| 2. Provide five to six specific, numbered recommendations. Reference the actual dollar amounts and percentages from the JSON. Each should be one or two sentences. | |
| 3. Flag any category where spending exceeds the recommended ceiling (the `limit_pct` field), and suggest a realistic target dollar amount. | |
| 4. Close with a single highest-priority action for the coming month. | |
| Be direct and honest, but constructive. Translate the numbers into decisions. | |
| Here is the budget: | |
| """ | |
| # ββ formatting helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def fmt(n: float) -> str: | |
| sign = "β" if n < 0 else "" | |
| return f"{sign}${abs(round(n)):,}" | |
| def pct(part: float, whole: float, decimals: int = 1) -> str: | |
| if whole == 0: | |
| return "0.0%" | |
| return f"{round(part / whole * 100, decimals)}%" | |
| # ββ HTML builders (return strings used by gr.HTML blocks) ββββββββββββββββββ | |
| def _kpi_row(gross, takehome, remaining): | |
| rem_cls = "accent" if remaining >= 0 else "warn" | |
| return f""" | |
| <div class="ba-kpi-row"> | |
| <div class="ba-kpi"> | |
| <div class="ba-kpi-label">Gross</div> | |
| <div class="ba-kpi-amount">{fmt(gross)}</div> | |
| <div class="ba-kpi-sub">100.0%</div> | |
| </div> | |
| <div class="ba-kpi accent"> | |
| <div class="ba-kpi-label">Take-home</div> | |
| <div class="ba-kpi-amount">{fmt(takehome)}</div> | |
| <div class="ba-kpi-sub">{pct(takehome, gross)} of gross</div> | |
| </div> | |
| <div class="ba-kpi {rem_cls}"> | |
| <div class="ba-kpi-label">Remaining</div> | |
| <div class="ba-kpi-amount">{fmt(remaining)}</div> | |
| <div class="ba-kpi-sub">{pct(remaining, takehome)} of take-home</div> | |
| </div> | |
| </div> | |
| """ | |
| def _flow_table(gross, fed, state, fica, mcare, k401, takehome): | |
| return f""" | |
| <div class="ba-section"> | |
| <div class="ba-section-head"> | |
| <h3>Income flow</h3> | |
| <span class="ba-hint">Withholdings & savings</span> | |
| </div> | |
| <table class="ba-ledger"> | |
| <thead> | |
| <tr><th>Line item</th><th>Amount</th><th>% gross</th></tr> | |
| </thead> | |
| <tbody> | |
| <tr><td>Gross income</td><td>{fmt(gross)}</td><td>100.0%</td></tr> | |
| <tr><td>Federal tax</td><td>β{fmt(fed)}</td><td>{pct(fed, gross)}</td></tr> | |
| <tr><td>State tax</td><td>β{fmt(state)}</td><td>{pct(state, gross)}</td></tr> | |
| <tr><td>Social Security</td><td>β{fmt(fica)}</td><td>{pct(fica, gross)}</td></tr> | |
| <tr><td>Medicare</td><td>β{fmt(mcare)}</td><td>{pct(mcare, gross)}</td></tr> | |
| <tr><td>401(k) contribution</td><td>β{fmt(k401)}</td><td>{pct(k401, gross)}</td></tr> | |
| <tr class="ba-total"><td>Take-home pay</td><td>{fmt(takehome)}</td><td>{pct(takehome, gross)}</td></tr> | |
| </tbody> | |
| </table> | |
| </div> | |
| """ | |
| def _spending_block(used, takehome): | |
| # stacked bar | |
| segs = [] | |
| rows = [] | |
| for cat, amt in used.items(): | |
| color = CAT_SWATCH.get(cat, "#8a8478") | |
| share = (amt / takehome * 100) if takehome > 0 else 0 | |
| limit = CAT_LIMITS.get(cat, 10) | |
| if share > limit: | |
| pill = f'<span class="ba-pill over">Over {limit}%</span>' | |
| elif share > limit * 0.8: | |
| pill = f'<span class="ba-pill near">Near {limit}%</span>' | |
| else: | |
| pill = "" | |
| # bar segments scale against take-home so empty space is remaining | |
| segs.append( | |
| f'<div class="ba-seg" style="width:{max(share,0):.2f}%;background:{color}"></div>' | |
| ) | |
| rows.append(f""" | |
| <div class="ba-cat-row"> | |
| <div class="ba-swatch" style="background:{color}"></div> | |
| <div class="ba-cat-name">{cat}</div> | |
| <div class="ba-cat-amt">{fmt(amt)}</div> | |
| <div class="ba-cat-pct">{share:.1f}%{pill}</div> | |
| </div> | |
| """) | |
| return f""" | |
| <div class="ba-section"> | |
| <div class="ba-section-head"> | |
| <h3>Spending allocation</h3> | |
| <span class="ba-hint">vs. take-home</span> | |
| </div> | |
| <div class="ba-bar">{''.join(segs)}</div> | |
| <div class="ba-cat-list">{''.join(rows)}</div> | |
| </div> | |
| """ | |
| def _position_block(total_exp, remaining, takehome, k401, gross): | |
| savings_rate = ((k401 + max(remaining, 0)) / gross * 100) if gross > 0 else 0 | |
| return f""" | |
| <div class="ba-section" style="margin-bottom:0"> | |
| <div class="ba-section-head"> | |
| <h3>Position</h3> | |
| <span class="ba-hint">end of month</span> | |
| </div> | |
| <table class="ba-ledger"> | |
| <tbody> | |
| <tr><td>Total expenses</td><td>{fmt(total_exp)}</td><td>{pct(total_exp, takehome)}</td></tr> | |
| <tr><td>Discretionary surplus</td><td>{fmt(max(remaining, 0))}</td><td>{pct(max(remaining, 0), takehome)}</td></tr> | |
| <tr class="ba-total"><td>Effective savings rate</td><td>{fmt(k401 + max(remaining, 0))}</td><td>{savings_rate:.1f}%</td></tr> | |
| </tbody> | |
| </table> | |
| </div> | |
| """ | |
| def _empty_report(message="Enter your income to generate the report."): | |
| return f""" | |
| <div class="ba-report-empty"> | |
| <div class="ba-kicker">Advisory report</div> | |
| <h2 class="ba-report-title">Awaiting inputs</h2> | |
| <p class="ba-report-sub"><em>{message}</em></p> | |
| </div> | |
| """ | |
| def _report_header(): | |
| now = datetime.now().strftime("%b %Y") | |
| return f""" | |
| <div class="ba-report-head"> | |
| <span class="ba-kicker">Advisory report</span> | |
| <span class="ba-meta">{now}</span> | |
| </div> | |
| <h2 class="ba-report-title">Monthly Position</h2> | |
| <p class="ba-report-sub"><em>Prepared from your declared inputs. Figures rounded to the nearest dollar.</em></p> | |
| """ | |
| # ββ core calculation βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def calculate_budget( | |
| gross_income, | |
| fed_tax_pct, state_tax_pct, fica_pct, medicare_pct, k401_pct, | |
| housing, food, transport, utilities, | |
| entertainment, health, education, clothing, other, | |
| ): | |
| if gross_income is None or gross_income <= 0: | |
| empty_json = "{}" | |
| return ( | |
| _empty_report("Please enter a valid monthly gross income to generate the report."), | |
| empty_json, FOLLOW_UP_PROMPT + empty_json, None, | |
| ) | |
| fed_amt = gross_income * fed_tax_pct / 100 | |
| state_amt = gross_income * state_tax_pct / 100 | |
| fica_amt = gross_income * fica_pct / 100 | |
| medicare_amt = gross_income * medicare_pct / 100 | |
| k401_amt = gross_income * k401_pct / 100 | |
| total_tax = fed_amt + state_amt + fica_amt + medicare_amt | |
| total_ded = total_tax + k401_amt | |
| takehome = gross_income - total_ded | |
| expenses = { | |
| "Housing": housing, | |
| "Food & groceries": food, | |
| "Transport": transport, | |
| "Utilities": utilities, | |
| "Entertainment": entertainment, | |
| "Health": health, | |
| "Education": education, | |
| "Clothing": clothing, | |
| "Other": other, | |
| } | |
| used = {k: (v or 0) for k, v in expenses.items() if v and v > 0} | |
| total_exp = sum(used.values()) | |
| remaining = takehome - total_exp | |
| # ββ report html ββ | |
| report_html = ( | |
| _report_header() | |
| + _kpi_row(gross_income, takehome, remaining) | |
| + _flow_table(gross_income, fed_amt, state_amt, fica_amt, medicare_amt, k401_amt, takehome) | |
| + _spending_block(used, takehome) | |
| + _position_block(total_exp, remaining, takehome, k401_amt, gross_income) | |
| ) | |
| # ββ structured JSON export ββ | |
| def _round(x): | |
| return round(float(x), 2) | |
| expense_breakdown = [] | |
| for cat, amt in used.items(): | |
| limit = CAT_LIMITS.get(cat, 10) | |
| cat_pct = round(amt / takehome * 100, 2) if takehome > 0 else 0 | |
| if cat_pct <= limit * 0.8: | |
| status = "ok" | |
| elif cat_pct <= limit: | |
| status = "near_limit" | |
| else: | |
| status = "over_limit" | |
| expense_breakdown.append({ | |
| "category": cat, | |
| "amount_usd": _round(amt), | |
| "pct_of_takehome": cat_pct, | |
| "limit_pct": limit, | |
| "status": status, | |
| }) | |
| export_data = { | |
| "schema": "budget-ai-advisor/v1", | |
| "generated_at": datetime.utcnow().isoformat() + "Z", | |
| "currency": "USD", | |
| "period": "monthly", | |
| "income": {"gross_usd": _round(gross_income)}, | |
| "pre_tax_deductions": { | |
| "federal_tax_pct": _round(fed_tax_pct), | |
| "state_tax_pct": _round(state_tax_pct), | |
| "social_security_pct": _round(fica_pct), | |
| "medicare_pct": _round(medicare_pct), | |
| "retirement_401k_pct": _round(k401_pct), | |
| "federal_tax_usd": _round(fed_amt), | |
| "state_tax_usd": _round(state_amt), | |
| "social_security_usd": _round(fica_amt), | |
| "medicare_usd": _round(medicare_amt), | |
| "retirement_401k_usd": _round(k401_amt), | |
| "total_taxes_usd": _round(total_tax), | |
| }, | |
| "take_home_usd": _round(takehome), | |
| "expenses": expense_breakdown, | |
| "totals": { | |
| "total_expenses_usd": _round(total_exp), | |
| "remaining_usd": _round(remaining), | |
| "remaining_pct_of_takehome": | |
| round(remaining / takehome * 100, 2) if takehome > 0 else 0, | |
| "savings_rate_pct_of_gross": | |
| round((k401_amt + max(remaining, 0)) / gross_income * 100, 2) | |
| if gross_income > 0 else 0, | |
| }, | |
| "notes": { | |
| "limits_reference": | |
| "Each expense's `limit_pct` is a recommended ceiling as a share of " | |
| "take-home pay. `status` is 'ok' if under 80% of the limit, " | |
| "'near_limit' if 80-100%, 'over_limit' if above.", | |
| "savings_rate_definition": | |
| "savings_rate_pct_of_gross = (401(k) contribution + leftover after expenses) / gross income", | |
| }, | |
| } | |
| export_json = json.dumps(export_data, indent=2) | |
| full_prompt = FOLLOW_UP_PROMPT + export_json | |
| timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") | |
| tmp_dir = tempfile.gettempdir() | |
| download_path = f"{tmp_dir}/budget-export-{timestamp}.json" | |
| with open(download_path, "w") as f: | |
| f.write(export_json) | |
| return report_html, export_json, full_prompt, download_path | |
| # ββ Custom CSS βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| APP_CSS = """ | |
| @import url('https://fonts.googleapis.com/css2?family=Source+Serif+4:ital,opsz,wght@0,8..60,300..700;1,8..60,400&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap'); | |
| :root, .gradio-container { | |
| --ba-bg: #faf7f2; | |
| --ba-bg-sunk: #f1ecdf; | |
| --ba-card: #fffdf8; | |
| --ba-ink: #1c1a17; | |
| --ba-ink-2: #4a463f; | |
| --ba-ink-3: #8a8478; | |
| --ba-rule: #e5dfd2; | |
| --ba-accent: oklch(0.42 0.05 155); | |
| --ba-warn: oklch(0.58 0.12 55); | |
| --ba-danger: oklch(0.50 0.15 30); | |
| --ba-serif: "Source Serif 4", "Iowan Old Style", Georgia, serif; | |
| --ba-sans: "Inter", -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif; | |
| --ba-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; | |
| } | |
| /* ββ Page shell βββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| html, body, .gradio-container { | |
| background: var(--ba-bg) !important; | |
| color: var(--ba-ink) !important; | |
| font-family: var(--ba-sans) !important; | |
| } | |
| .gradio-container { | |
| max-width: 1240px !important; | |
| margin: 0 auto !important; | |
| padding: 40px 48px 80px !important; | |
| } | |
| /* ββ Masthead + hero (rendered via gr.HTML) ββββββββββββββββββββ */ | |
| .ba-masthead { | |
| border-bottom: 1px solid var(--ba-ink); | |
| padding-bottom: 18px; | |
| margin-bottom: 28px; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: baseline; | |
| } | |
| .ba-brand { | |
| font-family: var(--ba-serif); | |
| font-weight: 400; | |
| font-style: italic; | |
| font-size: 22px; | |
| letter-spacing: -0.01em; | |
| } | |
| .ba-brand::before { | |
| content: "Β§"; | |
| margin-right: 8px; | |
| color: var(--ba-accent); | |
| font-style: normal; | |
| } | |
| .ba-brand-title { | |
| font-family: var(--ba-serif); | |
| font-style: normal; | |
| font-weight: 400; | |
| font-size: 15px; | |
| color: var(--ba-ink-2); | |
| margin-left: 12px; | |
| letter-spacing: 0.02em; | |
| } | |
| .ba-meta { | |
| font-family: var(--ba-mono); | |
| font-size: 11px; | |
| color: var(--ba-ink-3); | |
| letter-spacing: 0.08em; | |
| text-transform: uppercase; | |
| } | |
| .ba-hero { margin-bottom: 36px; max-width: 760px; } | |
| .ba-hero h1 { | |
| font-family: var(--ba-serif); | |
| font-weight: 400; | |
| font-size: 42px; | |
| line-height: 1.1; | |
| letter-spacing: -0.015em; | |
| margin: 0 0 14px; | |
| } | |
| .ba-hero h1 em { font-style: italic; color: var(--ba-accent); } | |
| .ba-hero p { | |
| font-family: var(--ba-serif); | |
| font-size: 16px; | |
| line-height: 1.55; | |
| color: var(--ba-ink-2); | |
| margin: 0; | |
| max-width: 640px; | |
| } | |
| /* ββ Gradio row / column as a two-column grid feels natural βββββ */ | |
| .gradio-container .prose { font-family: var(--ba-sans) !important; color: var(--ba-ink) !important; } | |
| /* Section headers we render as markdown */ | |
| .ba-panel-head { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: baseline; | |
| border-bottom: 1px solid var(--ba-ink); | |
| padding-bottom: 8px; | |
| margin: 24px 0 14px; | |
| } | |
| .ba-panel-head h2 { | |
| font-family: var(--ba-serif) !important; | |
| font-weight: 500 !important; | |
| font-size: 20px !important; | |
| margin: 0 !important; | |
| letter-spacing: -0.005em; | |
| color: var(--ba-ink) !important; | |
| } | |
| .ba-panel-head .ba-idx { | |
| font-family: var(--ba-mono); | |
| font-size: 10px; | |
| letter-spacing: 0.14em; | |
| color: var(--ba-ink-3); | |
| text-transform: uppercase; | |
| } | |
| .ba-panel-note { | |
| font-family: var(--ba-serif); | |
| font-style: italic; | |
| font-size: 14px; | |
| color: var(--ba-ink-3); | |
| margin: -4px 0 14px; | |
| } | |
| /* ββ Gradio input controls ββββββββββββββββββββββββββββββββββββ */ | |
| /* Labels */ | |
| .gradio-container label span, | |
| .gradio-container .label-wrap > span, | |
| .gradio-container .block > .label > span, | |
| .gradio-container span[data-testid="block-info"] { | |
| font-family: var(--ba-sans) !important; | |
| font-size: 13px !important; | |
| font-weight: 400 !important; | |
| color: var(--ba-ink-2) !important; | |
| letter-spacing: 0 !important; | |
| } | |
| /* Number inputs */ | |
| .gradio-container input[type="number"], | |
| .gradio-container input[type="text"] { | |
| font-family: var(--ba-mono) !important; | |
| font-size: 14px !important; | |
| text-align: right !important; | |
| background: var(--ba-card) !important; | |
| border: 1px solid var(--ba-rule) !important; | |
| color: var(--ba-ink) !important; | |
| border-radius: 2px !important; | |
| padding: 9px 12px !important; | |
| box-shadow: none !important; | |
| transition: border-color .15s; | |
| } | |
| .gradio-container input[type="number"]:focus, | |
| .gradio-container input[type="text"]:focus { | |
| border-color: var(--ba-ink) !important; | |
| outline: none !important; | |
| } | |
| /* Sliders */ | |
| .gradio-container input[type="range"] { | |
| accent-color: var(--ba-ink) !important; | |
| height: 2px !important; | |
| } | |
| .gradio-container .wrap.svelte-1cl284s, | |
| .gradio-container .wrap .head, | |
| .gradio-container .slider_input_container .tick-marks, | |
| .gradio-container [data-testid="slider"] { | |
| background: transparent !important; | |
| } | |
| /* Slider value readout β force mono */ | |
| .gradio-container .slider_input_container input, | |
| .gradio-container [data-testid="slider"] input { | |
| font-family: var(--ba-mono) !important; | |
| font-size: 12px !important; | |
| background: transparent !important; | |
| border: none !important; | |
| color: var(--ba-ink) !important; | |
| text-align: right !important; | |
| padding: 0 !important; | |
| width: 56px !important; | |
| } | |
| /* Remove Gradio's default card backgrounds on form blocks */ | |
| .gradio-container .block, | |
| .gradio-container .form, | |
| .gradio-container .gap, | |
| .gradio-container fieldset { | |
| background: transparent !important; | |
| border: none !important; | |
| box-shadow: none !important; | |
| } | |
| /* Give number/slider blocks a bottom rule to feel like a ledger */ | |
| .gradio-container .block.svelte-11xb1hd, | |
| .gradio-container [data-testid="number-input"], | |
| .gradio-container [data-testid="slider"] { | |
| border-bottom: 1px solid var(--ba-rule) !important; | |
| padding: 12px 0 !important; | |
| border-radius: 0 !important; | |
| } | |
| /* Button: turn Gradio primary into a serious black pill */ | |
| .gradio-container button.primary, | |
| .gradio-container button[variant="primary"], | |
| .gradio-container .primary button { | |
| background: var(--ba-ink) !important; | |
| color: var(--ba-bg) !important; | |
| border: 1px solid var(--ba-ink) !important; | |
| border-radius: 2px !important; | |
| font-family: var(--ba-sans) !important; | |
| font-weight: 500 !important; | |
| font-size: 13px !important; | |
| letter-spacing: 0.01em !important; | |
| padding: 11px 22px !important; | |
| box-shadow: none !important; | |
| transition: background .15s, border-color .15s; | |
| } | |
| .gradio-container button.primary:hover, | |
| .gradio-container button[variant="primary"]:hover { | |
| background: var(--ba-accent) !important; | |
| border-color: var(--ba-accent) !important; | |
| } | |
| .gradio-container button.secondary, | |
| .gradio-container button[variant="secondary"] { | |
| background: transparent !important; | |
| color: var(--ba-ink) !important; | |
| border: 1px solid var(--ba-ink) !important; | |
| border-radius: 2px !important; | |
| font-family: var(--ba-sans) !important; | |
| font-weight: 500 !important; | |
| font-size: 13px !important; | |
| padding: 11px 22px !important; | |
| box-shadow: none !important; | |
| } | |
| .gradio-container button.secondary:hover { | |
| background: var(--ba-bg-sunk) !important; | |
| } | |
| /* ββ Results sheet (gr.HTML output) βββββββββββββββββββββββββββ */ | |
| .ba-results-sheet { | |
| background: var(--ba-card); | |
| border: 1px solid var(--ba-rule); | |
| padding: 32px 36px; | |
| } | |
| .ba-report-head { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: baseline; | |
| } | |
| .ba-kicker { | |
| font-family: var(--ba-mono); | |
| font-size: 10px; | |
| letter-spacing: 0.14em; | |
| text-transform: uppercase; | |
| color: var(--ba-ink-3); | |
| } | |
| .ba-report-title { | |
| font-family: var(--ba-serif); | |
| font-size: 26px; | |
| font-weight: 400; | |
| letter-spacing: -0.01em; | |
| margin: 2px 0 4px; | |
| color: var(--ba-ink); | |
| } | |
| .ba-report-sub { | |
| font-family: var(--ba-serif); | |
| font-size: 13px; | |
| color: var(--ba-ink-3); | |
| margin: 0 0 22px; | |
| padding-bottom: 18px; | |
| border-bottom: 1px solid var(--ba-rule); | |
| } | |
| .ba-report-empty { | |
| padding: 12px 0 8px; | |
| } | |
| .ba-report-empty .ba-report-title { margin-top: 4px; } | |
| .ba-kpi-row { | |
| display: grid; | |
| grid-template-columns: repeat(3, 1fr); | |
| border: 1px solid var(--ba-rule); | |
| margin-bottom: 26px; | |
| } | |
| .ba-kpi { | |
| padding: 16px 16px; | |
| border-right: 1px solid var(--ba-rule); | |
| } | |
| .ba-kpi:last-child { border-right: none; } | |
| .ba-kpi-label { | |
| font-family: var(--ba-mono); | |
| font-size: 10px; | |
| letter-spacing: 0.10em; | |
| text-transform: uppercase; | |
| color: var(--ba-ink-3); | |
| margin-bottom: 6px; | |
| } | |
| .ba-kpi-amount { | |
| font-family: var(--ba-serif); | |
| font-size: 24px; | |
| font-weight: 400; | |
| letter-spacing: -0.01em; | |
| line-height: 1; | |
| color: var(--ba-ink); | |
| } | |
| .ba-kpi-sub { | |
| font-family: var(--ba-mono); | |
| font-size: 11px; | |
| color: var(--ba-ink-3); | |
| margin-top: 6px; | |
| } | |
| .ba-kpi.accent .ba-kpi-amount { color: var(--ba-accent); } | |
| .ba-kpi.warn .ba-kpi-amount { color: var(--ba-danger); } | |
| .ba-section { margin-bottom: 26px; } | |
| .ba-section-head { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: baseline; | |
| margin-bottom: 10px; | |
| } | |
| .ba-section-head h3 { | |
| font-family: var(--ba-serif); | |
| font-weight: 500; | |
| font-size: 15px; | |
| margin: 0; | |
| color: var(--ba-ink); | |
| } | |
| .ba-hint { | |
| font-family: var(--ba-mono); | |
| font-size: 10px; | |
| letter-spacing: 0.08em; | |
| text-transform: uppercase; | |
| color: var(--ba-ink-3); | |
| } | |
| .ba-ledger { | |
| width: 100%; | |
| border-collapse: collapse; | |
| font-size: 13px; | |
| } | |
| .ba-ledger th { | |
| font-family: var(--ba-mono); | |
| font-weight: 500; | |
| font-size: 10px; | |
| letter-spacing: 0.10em; | |
| text-transform: uppercase; | |
| color: var(--ba-ink-3); | |
| text-align: right; | |
| padding: 6px 0; | |
| border-bottom: 1px solid var(--ba-rule); | |
| } | |
| .ba-ledger th:first-child { text-align: left; } | |
| .ba-ledger td { | |
| padding: 9px 0; | |
| border-bottom: 1px dotted var(--ba-rule); | |
| font-family: var(--ba-mono); | |
| font-size: 12.5px; | |
| text-align: right; | |
| color: var(--ba-ink); | |
| } | |
| .ba-ledger td:first-child { | |
| text-align: left; | |
| font-family: var(--ba-sans); | |
| font-size: 13px; | |
| color: var(--ba-ink-2); | |
| } | |
| .ba-ledger tr.ba-total td { | |
| border-top: 1px solid var(--ba-ink); | |
| border-bottom: none; | |
| padding-top: 12px; | |
| font-weight: 600; | |
| color: var(--ba-ink); | |
| } | |
| .ba-ledger tr.ba-total td:first-child { | |
| font-family: var(--ba-serif); | |
| font-weight: 500; | |
| font-size: 14px; | |
| } | |
| .ba-bar { | |
| display: flex; | |
| height: 10px; | |
| width: 100%; | |
| background: var(--ba-bg-sunk); | |
| margin-bottom: 14px; | |
| overflow: hidden; | |
| } | |
| .ba-seg { height: 100%; border-right: 1px solid var(--ba-card); } | |
| .ba-seg:last-child { border-right: none; } | |
| .ba-cat-row { | |
| display: grid; | |
| grid-template-columns: 14px 1fr auto 130px; | |
| gap: 12px; | |
| align-items: center; | |
| padding: 8px 0; | |
| border-bottom: 1px dotted var(--ba-rule); | |
| font-size: 13px; | |
| } | |
| .ba-cat-row:last-child { border-bottom: none; } | |
| .ba-swatch { width: 10px; height: 10px; } | |
| .ba-cat-name { color: var(--ba-ink-2); } | |
| .ba-cat-amt { | |
| font-family: var(--ba-mono); | |
| font-size: 12.5px; | |
| color: var(--ba-ink); | |
| } | |
| .ba-cat-pct { | |
| font-family: var(--ba-mono); | |
| font-size: 11px; | |
| color: var(--ba-ink-3); | |
| text-align: right; | |
| } | |
| .ba-pill { | |
| display: inline-block; | |
| font-family: var(--ba-mono); | |
| font-size: 9px; | |
| letter-spacing: 0.08em; | |
| text-transform: uppercase; | |
| padding: 2px 6px; | |
| border: 1px solid; | |
| margin-left: 6px; | |
| vertical-align: 2px; | |
| } | |
| .ba-pill.over { color: var(--ba-danger); border-color: var(--ba-danger); } | |
| .ba-pill.near { color: var(--ba-warn); border-color: var(--ba-warn); } | |
| /* ββ Handoff section βββββββββββββββββββββββββββββββββββββββββ */ | |
| .ba-handoff-head { | |
| border-top: 1px solid var(--ba-ink); | |
| padding-top: 28px; | |
| margin-top: 40px; | |
| } | |
| .ba-handoff-head h2 { | |
| font-family: var(--ba-serif); | |
| font-weight: 400; | |
| font-size: 26px; | |
| letter-spacing: -0.01em; | |
| margin: 0 0 10px; | |
| } | |
| .ba-handoff-head p { | |
| font-family: var(--ba-serif); | |
| font-size: 15px; | |
| color: var(--ba-ink-2); | |
| max-width: 680px; | |
| margin: 0 0 22px; | |
| } | |
| .ba-steps { | |
| display: grid; | |
| grid-template-columns: repeat(3, 1fr); | |
| gap: 22px; | |
| margin-bottom: 20px; | |
| } | |
| .ba-step { border-top: 1px solid var(--ba-rule); padding-top: 12px; } | |
| .ba-step .ba-n { | |
| font-family: var(--ba-mono); | |
| font-size: 10px; | |
| letter-spacing: 0.14em; | |
| color: var(--ba-ink-3); | |
| text-transform: uppercase; | |
| margin-bottom: 6px; | |
| } | |
| .ba-step h4 { | |
| font-family: var(--ba-serif); | |
| font-weight: 500; | |
| font-size: 15px; | |
| margin: 0 0 4px; | |
| color: var(--ba-ink); | |
| } | |
| .ba-step p { | |
| font-family: var(--ba-sans); | |
| font-size: 13px; | |
| color: var(--ba-ink-2); | |
| margin: 0; | |
| } | |
| /* Gradio Code block override */ | |
| .gradio-container .cm-editor, | |
| .gradio-container .cm-content, | |
| .gradio-container pre, | |
| .gradio-container code { | |
| font-family: var(--ba-mono) !important; | |
| font-size: 12px !important; | |
| line-height: 1.55 !important; | |
| } | |
| .gradio-container .cm-editor { | |
| background: #1c1a17 !important; | |
| color: #e8e3d9 !important; | |
| border: 1px solid var(--ba-rule) !important; | |
| border-radius: 2px !important; | |
| } | |
| /* Accordion styling */ | |
| .gradio-container .label-wrap, | |
| .gradio-container details > summary { | |
| font-family: var(--ba-serif) !important; | |
| font-size: 15px !important; | |
| font-weight: 500 !important; | |
| color: var(--ba-ink) !important; | |
| padding: 10px 0 !important; | |
| border-bottom: 1px solid var(--ba-rule) !important; | |
| } | |
| /* File download component */ | |
| .gradio-container [data-testid="file"] { | |
| background: var(--ba-card) !important; | |
| border: 1px solid var(--ba-rule) !important; | |
| border-radius: 2px !important; | |
| } | |
| /* Footer text */ | |
| .ba-footer { | |
| margin-top: 56px; | |
| padding-top: 20px; | |
| border-top: 1px solid var(--ba-rule); | |
| display: flex; | |
| justify-content: space-between; | |
| font-family: var(--ba-mono); | |
| font-size: 10px; | |
| letter-spacing: 0.10em; | |
| text-transform: uppercase; | |
| color: var(--ba-ink-3); | |
| } | |
| """ | |
| APP_THEME = gr.themes.Base( | |
| primary_hue=gr.themes.colors.stone, | |
| secondary_hue=gr.themes.colors.stone, | |
| neutral_hue=gr.themes.colors.stone, | |
| font=("Inter", "system-ui", "sans-serif"), | |
| font_mono=("JetBrains Mono", "ui-monospace", "monospace"), | |
| ).set( | |
| body_background_fill="#faf7f2", | |
| body_text_color="#1c1a17", | |
| background_fill_primary="#faf7f2", | |
| background_fill_secondary="#faf7f2", | |
| border_color_primary="#e5dfd2", | |
| block_background_fill="transparent", | |
| block_border_width="0px", | |
| block_label_background_fill="transparent", | |
| button_primary_background_fill="#1c1a17", | |
| button_primary_text_color="#faf7f2", | |
| button_secondary_background_fill="transparent", | |
| button_secondary_text_color="#1c1a17", | |
| button_secondary_border_color="#1c1a17", | |
| input_background_fill="#fffdf8", | |
| input_border_color="#e5dfd2", | |
| ) | |
| # ββ Gradio UI ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| with gr.Blocks(title="Budget Advisor", theme=APP_THEME, css=APP_CSS) as demo: | |
| # Masthead | |
| gr.HTML(""" | |
| <div class="ba-masthead"> | |
| <div> | |
| <span class="ba-brand">Ledger & Co.</span> | |
| <span class="ba-brand-title">Personal Budget Advisor</span> | |
| </div> | |
| <span class="ba-meta">Monthly Β· USD</span> | |
| </div> | |
| <div class="ba-hero"> | |
| <h1>A measured view of your <em>monthly finances.</em></h1> | |
| <p>Enter your income, pre-tax deductions, and expenses below. The report compiles a structured view of where your money goes β and packages it as a handoff you can take to any AI advisor for personalized guidance.</p> | |
| </div> | |
| """) | |
| with gr.Row(equal_height=False): | |
| # βββ LEFT: inputs βββββββββββββββββββββββββββββββββββββββββββββ | |
| with gr.Column(scale=5, min_width=420): | |
| gr.HTML('<div class="ba-panel-head"><h2>Income</h2><span class="ba-idx">I.</span></div>') | |
| gross_income = gr.Number( | |
| label="Monthly gross income (USD)", value=5000, minimum=0, precision=0, | |
| ) | |
| gr.HTML('<div class="ba-panel-head"><h2>Pre-tax deductions</h2><span class="ba-idx">II.</span></div>' | |
| '<p class="ba-panel-note">Adjust to reflect your actual withholdings and retirement contributions.</p>') | |
| with gr.Row(): | |
| fed_tax = gr.Slider(0, 50, value=22, step=0.5, label="Federal income tax (%)") | |
| state_tax = gr.Slider(0, 15, value=5, step=0.5, label="State income tax (%)") | |
| with gr.Row(): | |
| fica = gr.Slider(0, 10, value=6.2, step=0.1, label="Social Security / FICA (%)") | |
| medicare = gr.Slider(0, 5, value=1.45, step=0.05, label="Medicare (%)") | |
| k401 = gr.Slider(0, 30, value=6, step=0.5, label="401(k) contribution (%)") | |
| gr.HTML('<div class="ba-panel-head"><h2>Monthly expenses</h2><span class="ba-idx">III.</span></div>' | |
| '<p class="ba-panel-note">Recurring categories with rough recommended ceilings as a share of take-home.</p>') | |
| with gr.Row(): | |
| housing = gr.Number(label="Housing (USD)", value=1200, minimum=0, precision=0) | |
| food = gr.Number(label="Food & groceries (USD)", value=400, minimum=0, precision=0) | |
| transport = gr.Number(label="Transport (USD)", value=200, minimum=0, precision=0) | |
| with gr.Row(): | |
| utilities = gr.Number(label="Utilities (USD)", value=150, minimum=0, precision=0) | |
| entertainment = gr.Number(label="Entertainment (USD)", value=100, minimum=0, precision=0) | |
| health = gr.Number(label="Health (USD)", value=0, minimum=0, precision=0) | |
| with gr.Row(): | |
| education = gr.Number(label="Education (USD)", value=0, minimum=0, precision=0) | |
| clothing = gr.Number(label="Clothing (USD)", value=0, minimum=0, precision=0) | |
| other = gr.Number(label="Other (USD)", value=0, minimum=0, precision=0) | |
| calc_btn = gr.Button("Compile report", variant="primary", size="lg") | |
| # βββ RIGHT: results sheet βββββββββββββββββββββββββββββββββββββ | |
| with gr.Column(scale=6, min_width=480): | |
| report_html = gr.HTML( | |
| value='<div class="ba-results-sheet">' + _empty_report() + '</div>', | |
| ) | |
| # Handoff section | |
| gr.HTML(""" | |
| <div class="ba-handoff-head"> | |
| <h2>Take this report further</h2> | |
| <p>Your budget has been packaged as a structured handoff β a carefully written prompt followed by your figures as JSON. Paste the whole block into the AI assistant of your choice for a personalized read.</p> | |
| <div class="ba-steps"> | |
| <div class="ba-step"><div class="ba-n">Step 01</div><h4>Verify your inputs</h4><p>Adjust the sliders and expense fields until the report reflects your actual situation.</p></div> | |
| <div class="ba-step"><div class="ba-n">Step 02</div><h4>Copy the handoff</h4><p>Use the button below to copy the prompt and JSON in one click, or download the JSON as a file.</p></div> | |
| <div class="ba-step"><div class="ba-n">Step 03</div><h4>Paste into your advisor</h4><p>Any capable assistant β Claude, ChatGPT, or your own β will produce an actionable read.</p></div> | |
| </div> | |
| </div> | |
| """) | |
| with gr.Accordion("Ready-to-paste prompt and budget data", open=True): | |
| out_full_prompt = gr.Code( | |
| label="Prompt + JSON (copy the whole block)", | |
| language="markdown", | |
| interactive=False, | |
| lines=18, | |
| ) | |
| with gr.Row(): | |
| copy_btn = gr.Button("Copy handoff to clipboard", variant="primary") | |
| out_download = gr.File(label="Or download the JSON", interactive=False) | |
| with gr.Accordion("Raw JSON only", open=False): | |
| out_json = gr.Code( | |
| label="Budget data as JSON", | |
| language="json", | |
| interactive=False, | |
| lines=18, | |
| ) | |
| gr.HTML('<div class="ba-footer"><span>Ledger & Co. Β· Not professional financial advice</span><span>Report compiled locally in-browser</span></div>') | |
| # ββ wire up (computes on button click + on any input change) βββ | |
| inputs = [ | |
| gross_income, | |
| fed_tax, state_tax, fica, medicare, k401, | |
| housing, food, transport, utilities, | |
| entertainment, health, education, clothing, other, | |
| ] | |
| outputs = [report_html, out_json, out_full_prompt, out_download] | |
| calc_btn.click(fn=calculate_budget, inputs=inputs, outputs=outputs) | |
| # Live-update so the report reflects input changes without needing the button | |
| for ctrl in inputs: | |
| ctrl.change(fn=calculate_budget, inputs=inputs, outputs=outputs) | |
| # compute initial report on load | |
| demo.load(fn=calculate_budget, inputs=inputs, outputs=outputs) | |
| # Clipboard copy runs in the browser β no server round-trip needed. | |
| copy_btn.click( | |
| fn=None, | |
| inputs=[out_full_prompt], | |
| outputs=[], | |
| js=""" | |
| (text) => { | |
| if (!text) { | |
| alert("Adjust your inputs first, then try again."); | |
| return; | |
| } | |
| navigator.clipboard.writeText(text).then(() => { | |
| alert("Copied. Paste it into your AI assistant of choice."); | |
| }).catch(() => { | |
| alert("Couldn't copy automatically β select the text in the box and copy it manually."); | |
| }); | |
| } | |
| """, | |
| ) | |
| if __name__ == "__main__": | |
| demo.launch() | |