| |
| """Interactive app harness for simple business-owner apps. |
| |
| This creates complete one-file web apps with local persistence. The goal is not |
| to replace real engineering for complex SaaS products. It is to make common |
| small-business utility apps fast, complete, and demonstrable. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import html |
| import json |
| import re |
| from dataclasses import dataclass, field |
| from pathlib import Path |
| from typing import Any |
|
|
|
|
| @dataclass |
| class AppSpec: |
| app_name: str |
| app_type: str |
| headline: str |
| entities: list[str] = field(default_factory=list) |
| fields: list[str] = field(default_factory=list) |
| actions: list[str] = field(default_factory=list) |
| accent: str = "#f4ad32" |
|
|
|
|
| APP_DEFAULTS: dict[str, dict[str, list[str] | str]] = { |
| "booking": { |
| "headline": "Book appointments and keep the day organized.", |
| "entities": ["appointments"], |
| "fields": ["Customer", "Service", "Date", "Time", "Phone"], |
| "actions": ["Add appointment", "Mark complete", "Export CSV"], |
| }, |
| "crm": { |
| "headline": "Track leads from first message to paid customer.", |
| "entities": ["leads"], |
| "fields": ["Name", "Company", "Need", "Status", "Next Follow-up"], |
| "actions": ["Add lead", "Update status", "Export CSV"], |
| }, |
| "invoice_tracker": { |
| "headline": "Track invoices, payment status, and follow-ups.", |
| "entities": ["invoices"], |
| "fields": ["Client", "Invoice #", "Amount", "Status", "Due Date"], |
| "actions": ["Add invoice", "Mark paid", "Export CSV"], |
| }, |
| "inventory": { |
| "headline": "Keep inventory clear without a heavy system.", |
| "entities": ["items"], |
| "fields": ["Item", "Category", "Quantity", "Reorder At", "Supplier"], |
| "actions": ["Add item", "Update quantity", "Export CSV"], |
| }, |
| "task_board": { |
| "headline": "Turn scattered work into a clear action board.", |
| "entities": ["tasks"], |
| "fields": ["Task", "Owner", "Priority", "Status", "Due Date"], |
| "actions": ["Add task", "Mark complete", "Export CSV"], |
| }, |
| "estimate_builder": { |
| "headline": "Build quotes and estimates without losing the details.", |
| "entities": ["estimates"], |
| "fields": ["Client", "Service", "Labor Hours", "Materials", "Estimate Total", "Status"], |
| "actions": ["Add estimate", "Mark approved", "Export CSV"], |
| }, |
| "content_calendar": { |
| "headline": "Plan posts, hooks, channels, and publish dates in one place.", |
| "entities": ["content ideas"], |
| "fields": ["Idea", "Channel", "Hook", "Publish Date", "Status", "Owner"], |
| "actions": ["Add idea", "Mark published", "Export CSV"], |
| }, |
| "expense_tracker": { |
| "headline": "Track expenses and cash movement before it gets messy.", |
| "entities": ["expenses"], |
| "fields": ["Vendor", "Category", "Amount", "Payment Method", "Date", "Status"], |
| "actions": ["Add expense", "Mark reviewed", "Export CSV"], |
| }, |
| } |
|
|
|
|
| def clean_text(value: Any, fallback: str) -> str: |
| if not isinstance(value, str): |
| return fallback |
| value = re.sub(r"\s+", " ", value).strip() |
| return value or fallback |
|
|
|
|
| def infer_app_type(prompt: str) -> str: |
| lower = prompt.lower() |
| if any(term in lower for term in ["content calendar", "post calendar", "social calendar", "marketing calendar", "content planner", "post flow"]): |
| return "content_calendar" |
| if any(term in lower for term in ["expense", "budget", "cash flow", "spend", "receipt", "payment method"]): |
| return "expense_tracker" |
| if any(term in lower for term in ["estimate", "quote", "bid", "proposal calculator", "price calculator"]): |
| return "estimate_builder" |
| if any(term in lower for term in ["inventory", "stock", "reorder"]): |
| return "inventory" |
| if any(term in lower for term in ["crm", "lead", "prospect", "pipeline"]): |
| return "crm" |
| if any(term in lower for term in ["invoice", "paid", "payment"]): |
| return "invoice_tracker" |
| if any(term in lower for term in ["booking", "appointment", "calendar", "schedule"]): |
| return "booking" |
| return "task_board" |
|
|
|
|
| def infer_app_name(prompt: str, app_type: str) -> str: |
| stop_words = r"\s+(?:for|with|to|that|using|include|including|built|as)\b" |
| patterns = [ |
| rf"named\s+([A-Z][A-Za-z0-9 &'&-]{{2,70}}?)(?:\.|,|{stop_words}|$)", |
| rf"called\s+([A-Z][A-Za-z0-9 &'&-]{{2,70}}?)(?:\.|,|{stop_words}|$)", |
| ] |
| for pattern in patterns: |
| match = re.search(pattern, prompt, flags=re.IGNORECASE) |
| if match: |
| return clean_text(match.group(1), "Business App").rstrip(".") |
| names = { |
| "booking": "Booking Desk", |
| "crm": "Lead Desk", |
| "invoice_tracker": "Invoice Desk", |
| "inventory": "Inventory Desk", |
| "task_board": "Work Desk", |
| "estimate_builder": "Estimate Desk", |
| "content_calendar": "Content Desk", |
| "expense_tracker": "Expense Desk", |
| } |
| return names.get(app_type, "Business App") |
|
|
|
|
| def spec_from_prompt(prompt: str) -> AppSpec: |
| app_type = infer_app_type(prompt) |
| defaults = APP_DEFAULTS[app_type] |
| return AppSpec( |
| app_name=infer_app_name(prompt, app_type), |
| app_type=app_type, |
| headline=str(defaults["headline"]), |
| entities=list(defaults["entities"]), |
| fields=list(defaults["fields"]), |
| actions=list(defaults["actions"]), |
| accent="#38bdf8" if app_type in {"crm", "invoice_tracker", "content_calendar"} else "#f4ad32", |
| ) |
|
|
|
|
| def normalize_spec(raw: dict[str, Any] | AppSpec, prompt: str = "") -> AppSpec: |
| if isinstance(raw, AppSpec): |
| spec = raw |
| else: |
| fallback = spec_from_prompt(prompt) |
| fields = raw.get("fields") if isinstance(raw.get("fields"), list) else fallback.fields |
| actions = raw.get("actions") if isinstance(raw.get("actions"), list) else fallback.actions |
| entities = raw.get("entities") if isinstance(raw.get("entities"), list) else fallback.entities |
| spec = AppSpec( |
| app_name=clean_text(raw.get("app_name"), fallback.app_name), |
| app_type=clean_text(raw.get("app_type"), fallback.app_type).lower(), |
| headline=clean_text(raw.get("headline"), fallback.headline), |
| entities=[clean_text(item, "") for item in entities if isinstance(item, str)][:4] or fallback.entities, |
| fields=[clean_text(item, "") for item in fields if isinstance(item, str)][:8] or fallback.fields, |
| actions=[clean_text(item, "") for item in actions if isinstance(item, str)][:6] or fallback.actions, |
| accent=clean_text(raw.get("accent"), fallback.accent), |
| ) |
| if spec.app_type not in APP_DEFAULTS: |
| spec.app_type = infer_app_type(prompt) |
| inferred_app_type = infer_app_type(prompt) |
| if prompt and inferred_app_type != spec.app_type: |
| previous_defaults = APP_DEFAULTS.get(spec.app_type, {}) |
| previous_headline = str(previous_defaults.get("headline", "")) |
| spec.app_type = inferred_app_type |
| if spec.headline == previous_headline or "book appointments" in spec.headline.lower(): |
| spec.headline = str(APP_DEFAULTS[spec.app_type]["headline"]) |
| if len(spec.fields) < 3: |
| spec.fields = list(APP_DEFAULTS[spec.app_type]["fields"]) |
| if len(spec.actions) < 2: |
| spec.actions = list(APP_DEFAULTS[spec.app_type]["actions"]) |
| return spec |
|
|
|
|
| def esc(value: str) -> str: |
| return html.escape(value, quote=True) |
|
|
|
|
| def slugify(value: str) -> str: |
| return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") or "kaiju-app" |
|
|
|
|
| def sample_value(field: str, app_type: str) -> str: |
| lower = field.lower() |
| if "name" in lower or "client" in lower or "customer" in lower: |
| return "Jordan Lee" |
| if "company" in lower: |
| return "Bright Path Studio" |
| if "service" in lower: |
| return "Premium package" |
| if "date" in lower or "due" in lower or "follow" in lower or "publish" in lower: |
| return "2026-05-15" |
| if "time" in lower: |
| return "10:30 AM" |
| if "phone" in lower: |
| return "(404) 555-0199" |
| if "amount" in lower or "total" in lower or "materials" in lower: |
| return "$450" |
| if "hour" in lower: |
| return "4" |
| if "status" in lower: |
| return "Open" |
| if "priority" in lower: |
| return "High" |
| if "owner" in lower: |
| return "Richard" |
| if "category" in lower: |
| return "Retail" |
| if "quantity" in lower: |
| return "24" |
| if "reorder" in lower: |
| return "10" |
| if "supplier" in lower or "vendor" in lower: |
| return "Main Street Supply" |
| if "item" in lower: |
| return "Signature candles" |
| if "idea" in lower: |
| return "Before-and-after demo" |
| if "hook" in lower: |
| return "Stop losing leads in your notes" |
| if "channel" in lower: |
| return "YouTube" |
| if "payment" in lower: |
| return "Business card" |
| if "invoice" in lower: |
| return "INV-1007" |
| if "task" in lower: |
| return "Call warm leads" |
| if "need" in lower: |
| return "Website and checkout" |
| return f"{app_type.replace('_', ' ').title()} sample" |
|
|
|
|
| def render_field_inputs(fields: list[str]) -> str: |
| return "\n".join( |
| f'<label>{esc(field)}<input name="{esc(slugify(field))}" placeholder="{esc(field)}" required></label>' |
| for field in fields |
| ) |
|
|
|
|
| def render_table_headers(fields: list[str]) -> str: |
| return "\n".join(f"<th>{esc(field)}</th>" for field in fields) |
|
|
|
|
| def render_html(raw_spec: dict[str, Any] | AppSpec, prompt: str = "") -> str: |
| spec = normalize_spec(raw_spec, prompt) |
| storage_key = f"kaiju-{slugify(spec.app_name)}" |
| inputs = render_field_inputs(spec.fields) |
| headers = render_table_headers(spec.fields) |
| field_names = json.dumps([slugify(field) for field in spec.fields]) |
| field_labels = json.dumps(spec.fields) |
| sample = json.dumps({slugify(field): sample_value(field, spec.app_type) for field in spec.fields}) |
| return f"""<!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>{esc(spec.app_name)} | Kaiju App</title> |
| <style> |
| :root{{--bg:#0b0f17;--panel:#121823;--card:#171f2d;--ink:#f8fafc;--muted:#94a3b8;--line:rgba(148,163,184,.2);--accent:{esc(spec.accent)}}}*{{box-sizing:border-box}}body{{margin:0;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:radial-gradient(circle at top right,rgba(56,189,248,.22),transparent 32%),var(--bg);color:var(--ink)}}button,input,select{{font:inherit}}.app{{max-width:1180px;margin:auto;padding:34px 28px}}header{{display:flex;justify-content:space-between;gap:20px;align-items:start;margin-bottom:34px}}.brand{{font-weight:950;letter-spacing:-.035em;font-size:28px}}.pill{{border:1px solid var(--line);border-radius:999px;padding:9px 13px;color:var(--muted);font-weight:800}}h1{{font-size:clamp(40px,5.4vw,64px);line-height:.98;letter-spacing:-.045em;margin:20px 0 12px;max-width:860px}}p{{color:var(--muted);line-height:1.65;font-size:18px}}.grid{{display:grid;grid-template-columns:380px 1fr;gap:20px;align-items:start}}.panel{{background:linear-gradient(180deg,rgba(255,255,255,.04),rgba(255,255,255,.015));border:1px solid var(--line);border-radius:28px;padding:22px;box-shadow:0 24px 80px rgba(0,0,0,.24)}}label{{display:block;color:var(--muted);font-weight:850;margin-bottom:12px}}input,select{{width:100%;margin-top:7px;border:1px solid var(--line);background:#0e1420;color:var(--ink);border-radius:14px;padding:13px}}button{{border:0;border-radius:15px;background:var(--accent);color:#07111f;font-weight:950;padding:13px 16px;cursor:pointer}}.secondary{{background:#253044;color:var(--ink)}}.actions{{display:flex;gap:10px;flex-wrap:wrap;margin-top:16px}}table{{width:100%;border-collapse:collapse;overflow:hidden;border-radius:20px}}th,td{{text-align:left;border-bottom:1px solid var(--line);padding:13px;vertical-align:top}}th{{color:var(--muted);font-size:13px;text-transform:uppercase;letter-spacing:.1em}}.empty{{padding:28px;border:1px dashed var(--line);border-radius:20px;color:var(--muted)}}.statbar{{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin:22px 0}}.stat{{background:var(--card);border:1px solid var(--line);border-radius:18px;padding:16px}}.stat strong{{display:block;font-size:26px}}@media(max-width:880px){{header,.grid{{display:block}}.panel{{margin-bottom:18px}}h1{{font-size:42px}}.statbar{{grid-template-columns:1fr}}table{{display:block;overflow-x:auto}}}} |
| </style> |
| </head> |
| <body> |
| <div class="app"> |
| <header><div class="brand">{esc(spec.app_name)}</div><div class="pill">Local-first {esc(spec.app_type.replace("_", " "))}</div></header> |
| <main> |
| <p class="pill" style="display:inline-block">Kaiju one-file app</p> |
| <h1>{esc(spec.headline)}</h1> |
| <p>Built for business owners who need a working tool now: form entry, saved records, clear status, and CSV export without a backend.</p> |
| <div class="statbar"><div class="stat"><strong id="totalCount">0</strong><span>Total records</span></div><div class="stat"><strong id="todayCount">0</strong><span>Added today</span></div><div class="stat"><strong id="storageState">Ready</strong><span>Local storage</span></div></div> |
| <div class="grid"> |
| <section class="panel"> |
| <h2>Add record</h2> |
| <form id="recordForm"> |
| {inputs} |
| <div class="actions"><button type="submit">{esc(spec.actions[0])}</button><button class="secondary" type="button" id="loadSample">Load sample</button></div> |
| </form> |
| </section> |
| <section class="panel"> |
| <div style="display:flex;justify-content:space-between;gap:14px;align-items:center;margin-bottom:16px"><h2 style="margin:0">Records</h2><div class="actions" style="margin:0"><button class="secondary" type="button" id="exportCsv">Export CSV</button><button class="secondary" type="button" id="clearAll">Clear all</button></div></div> |
| <div id="emptyState" class="empty">No records yet. Add the first one from the form.</div> |
| <table id="recordsTable" hidden><thead><tr>{headers}<th>Created</th><th>Action</th></tr></thead><tbody></tbody></table> |
| </section> |
| </div> |
| </main> |
| </div> |
| <script> |
| const STORAGE_KEY = "{storage_key}"; |
| const FIELDS = {field_names}; |
| const LABELS = {field_labels}; |
| const SAMPLE = {sample}; |
| const form = document.getElementById("recordForm"); |
| const table = document.getElementById("recordsTable"); |
| const tbody = table.querySelector("tbody"); |
| const emptyState = document.getElementById("emptyState"); |
| const totalCount = document.getElementById("totalCount"); |
| const todayCount = document.getElementById("todayCount"); |
| const storageState = document.getElementById("storageState"); |
| |
| function readRecords() {{ |
| try {{ |
| return JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]"); |
| }} catch (_error) {{ |
| return []; |
| }} |
| }} |
| |
| function writeRecords(records) {{ |
| localStorage.setItem(STORAGE_KEY, JSON.stringify(records)); |
| render(); |
| }} |
| |
| function formToRecord() {{ |
| const data = new FormData(form); |
| const record = {{ id: crypto.randomUUID(), createdAt: new Date().toISOString() }}; |
| for (const field of FIELDS) record[field] = String(data.get(field) || "").trim(); |
| return record; |
| }} |
| |
| function render() {{ |
| const records = readRecords(); |
| tbody.innerHTML = ""; |
| const today = new Date().toISOString().slice(0, 10); |
| totalCount.textContent = String(records.length); |
| todayCount.textContent = String(records.filter(record => record.createdAt.slice(0, 10) === today).length); |
| storageState.textContent = "Ready"; |
| emptyState.hidden = records.length > 0; |
| table.hidden = records.length === 0; |
| for (const record of records) {{ |
| const row = document.createElement("tr"); |
| row.innerHTML = FIELDS.map(field => `<td>${{escapeHtml(record[field] || "")}}</td>`).join("") + |
| `<td>${{new Date(record.createdAt).toLocaleString()}}</td><td><button class="secondary" data-delete="${{record.id}}">Delete</button></td>`; |
| tbody.appendChild(row); |
| }} |
| }} |
| |
| function escapeHtml(value) {{ |
| return value.replace(/[&<>"']/g, char => ({{ "&": "&", "<": "<", ">": ">", "\\"": """, "'": "'" }}[char])); |
| }} |
| |
| form.addEventListener("submit", event => {{ |
| event.preventDefault(); |
| const record = formToRecord(); |
| if (FIELDS.some(field => !record[field])) return; |
| writeRecords([record, ...readRecords()]); |
| form.reset(); |
| }}); |
| |
| tbody.addEventListener("click", event => {{ |
| const button = event.target.closest("[data-delete]"); |
| if (!button) return; |
| writeRecords(readRecords().filter(record => record.id !== button.dataset.delete)); |
| }}); |
| |
| document.getElementById("clearAll").addEventListener("click", () => {{ |
| if (confirm("Clear all saved records for this app?")) writeRecords([]); |
| }}); |
| |
| document.getElementById("loadSample").addEventListener("click", () => {{ |
| for (const field of FIELDS) {{ |
| const input = Array.from(form.elements).find(element => element.name === field); |
| if (input) input.value = SAMPLE[field] || LABELS[FIELDS.indexOf(field)] + " example"; |
| }} |
| }}); |
| |
| document.getElementById("exportCsv").addEventListener("click", () => {{ |
| const records = readRecords(); |
| const header = [...LABELS, "Created"].join(","); |
| const lines = records.map(record => [...FIELDS.map(field => record[field] || ""), record.createdAt].map(value => `"${{String(value).replaceAll('"', '""')}}"`).join(",")); |
| const blob = new Blob([[header, ...lines].join("\\n")], {{ type: "text/csv" }}); |
| const url = URL.createObjectURL(blob); |
| const link = document.createElement("a"); |
| link.href = url; |
| link.download = "{slugify(spec.app_name)}.csv"; |
| link.click(); |
| URL.revokeObjectURL(url); |
| }}); |
| |
| render(); |
| </script> |
| </body> |
| </html>""" |
|
|
|
|
| def validate_html(rendered: str, spec: AppSpec | None = None) -> list[str]: |
| lower = rendered.lower() |
| errors: list[str] = [] |
| for token in ["<!doctype html", "<html", "<head", "</head>", "<body", "</body>", "</html>"]: |
| if token not in lower: |
| errors.append(f"missing {token}") |
| if not lower.strip().endswith("</html>"): |
| errors.append("document does not end with </html>") |
| for token in ["<form", "<script", "localstorage", "addeventlistener", "export csv"]: |
| if token not in lower: |
| errors.append(f"missing app token: {token}") |
| sample_match = re.search(r"const\s+SAMPLE\s*=\s*(\{.*?\});", rendered, flags=re.DOTALL) |
| if not sample_match: |
| errors.append("missing sample data") |
| else: |
| try: |
| sample_data = json.loads(sample_match.group(1)) |
| if not sample_data or any(not str(value).strip() for value in sample_data.values()): |
| errors.append("sample data contains blank values") |
| except json.JSONDecodeError: |
| errors.append("sample data is not valid JSON") |
| if "@media" not in lower or "viewport" not in lower: |
| errors.append("missing responsive app styling") |
| if "```" in rendered: |
| errors.append("markdown fence found") |
| if spec: |
| for field in spec.fields[:3]: |
| if field.lower() not in lower: |
| errors.append(f"missing field: {field}") |
| return errors |
|
|
|
|
| def render_from_prompt(prompt: str) -> tuple[AppSpec, str, list[str]]: |
| spec = spec_from_prompt(prompt) |
| rendered = render_html(spec, prompt) |
| return spec, rendered, validate_html(rendered, spec) |
|
|
|
|
| def write_html(path: Path, rendered: str) -> None: |
| path.parent.mkdir(parents=True, exist_ok=True) |
| path.write_text(rendered, encoding="utf-8") |
|
|
|
|
| def spec_to_json(spec: AppSpec) -> str: |
| return json.dumps(spec.__dict__, indent=2, ensure_ascii=False) |
|
|