File size: 19,662 Bytes
c75f885
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
#!/usr/bin/env python3
"""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 => ({{ "&": "&amp;", "<": "&lt;", ">": "&gt;", "\\"": "&quot;", "'": "&#39;" }}[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)