# Abstra Pages — Annotated Examples

This file is for AI assistants only. Study these examples before writing any Page stage code. Key concepts:
- `@register_function` makes Python functions callable as async JS functions from the browser
- `__render__()` is special: called on GET, must return an HTML string (never exposed to JS)
- Generator functions (`yield`) stream NDJSON — the JS stub becomes an async iterable
- `get_user()` triggers login if unauthenticated; returns `UserClaims` with `.email` and `.roles`
- `get_query_params()` reads URL query params as `Dict[str, str]`
- `register_static(path)` serves local files (JS, CSS, images) via a signed URL — returns the URL string
- In f-strings inside `__render__`, double all braces: `{{` and `}}` for JS/CSS
- Multi-page apps share layout via a Jinja `base.html` — never via `<iframe>` embedding of other Pages (iframes stack the navbar twice)

## Example 1: Dashboard with Auto-Refresh
**Patterns:** `@register_function`, `cache=` for fewer worker round-trips, `fn.clearCache()` after mutations, Tailwind, `setInterval` polling, currency formatting, action buttons

`cache=N` makes the generated JS stub return a cached value for N seconds — no worker round-trip. Keyed by positional args, in-memory per page load, with in-flight dedupe. Call `fn.clearCache()` after a mutation so the next render isn't stale. Not allowed on generators or `__render__`.

```python
import random
from datetime import datetime, timedelta
from abstra.pages import register_function

def _cash_flow_item():
    items = [("Accounts Receivable","inflow"),("Supplier Payment","outflow"),("Payroll","outflow"),("Service Revenue","inflow")]
    name, direction = random.choice(items)
    return {"description": name, "direction": direction, "amount": round(random.uniform(10_000, 500_000), 2),
            "due_date": (datetime.now() + timedelta(days=random.randint(0, 30))).strftime("%Y-%m-%d")}

@register_function(cache=20)  # 10s polling × 20s cache → ~1 worker call per 20s
def get_dashboard_data():
    inflows = round(random.uniform(200_000, 900_000), 2)
    outflows = round(random.uniform(100_000, 600_000), 2)
    return {"balance": round(random.uniform(2_000_000, 8_000_000), 2), "inflows": inflows, "outflows": outflows,
            "net": round(inflows - outflows, 2), "cash_flow": [_cash_flow_item() for _ in range(5)]}

@register_function
def approve_payment(index: int):
    return {"status": "approved", "index": index}

@register_function
def __render__():
    return """
<script src="https://cdn.tailwindcss.com"></script>
<style>body{margin:0;font-family:'Inter',system-ui,sans-serif;background:#f8fafc}</style>
<div class="min-h-screen p-8 max-w-6xl mx-auto">
  <div class="flex items-center justify-between mb-8">
    <h1 class="text-2xl font-bold text-slate-800">Treasury Dashboard</h1>
    <button onclick="get_dashboard_data.clearCache(); refreshData()" class="bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium px-5 py-2.5 rounded-lg">Refresh</button>
  </div>
  <div class="grid grid-cols-2 lg:grid-cols-4 gap-5 mb-8" id="cards"></div>
  <div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
    <table class="w-full text-sm">
      <thead><tr class="text-left text-xs font-semibold text-slate-400 uppercase border-b border-slate-50">
        <th class="px-6 py-3">Description</th><th class="px-6 py-3">Due</th>
        <th class="px-6 py-3 text-right">Amount</th><th class="px-6 py-3 text-center">Action</th>
      </tr></thead>
      <tbody id="cash-flow"></tbody>
    </table>
  </div>
</div>
<script>
const fmt = n => new Intl.NumberFormat('pt-BR',{style:'currency',currency:'BRL'}).format(n);
function card(label,value,color) {
  return `<div class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
    <p class="text-xs font-semibold text-${color}-500 uppercase mb-3">${label}</p>
    <p class="text-2xl font-bold text-${color}-600">${value}</p></div>`;
}
async function refreshData() {
  const d = await get_dashboard_data();
  document.getElementById('cards').innerHTML =
    card('Balance',fmt(d.balance),'slate') + card('Inflows',fmt(d.inflows),'emerald') +
    card('Outflows',fmt(d.outflows),'rose') + card('Net',(d.net>=0?'+':'')+fmt(d.net), d.net>=0?'emerald':'rose');
  document.getElementById('cash-flow').innerHTML = d.cash_flow.map((item,i) => `
    <tr class="border-b border-slate-50 hover:bg-slate-50/50">
      <td class="px-6 py-3.5 font-medium text-slate-700">${item.description}</td>
      <td class="px-6 py-3.5 text-slate-400">${item.due_date}</td>
      <td class="px-6 py-3.5 text-right font-mono ${item.direction==='inflow'?'text-emerald-600':'text-rose-600'}">
        ${item.direction==='inflow'?'+':'-'} ${fmt(item.amount)}</td>
      <td class="px-6 py-3.5 text-center">${item.direction==='outflow'
        ? `<button onclick="handleApprove(${i})" class="text-xs bg-indigo-50 text-indigo-700 hover:bg-indigo-100 font-medium px-3 py-1.5 rounded-lg">Approve</button>` : '-'}</td>
    </tr>`).join('');
}
async function handleApprove(i) { await approve_payment(i); get_dashboard_data.clearCache(); refreshData(); }
refreshData();
setInterval(refreshData, 10000);
</script>
"""
```

## Example 2: Authenticated Task Manager
**Patterns:** `get_user()` for auth, role-based permission, database queries, empty state

```python
from datetime import date, timedelta

from abstra.pages import register_function, get_user
from abstra.tables import select, update, delete, delete_by_id, is_lte

UPCOMING_WINDOW_DAYS = 14

@register_function
def get_my_tasks():
    user = get_user()
    cutoff = (date.today() + timedelta(days=UPCOMING_WINDOW_DAYS)).isoformat()
    tasks = select(
        "tasks",
        where={
            "assignee_email": user.email,
            "status": "pending",
            "due_date": is_lte(cutoff),
        },
        order_by="due_date",
    )
    return [{"id": t["id"], "title": t["title"], "due": str(t["due_date"]), "priority": t.get("priority","medium")} for t in tasks]

@register_function
def complete_task(task_id: str):
    user = get_user()
    if "manager" not in user.roles and user.email != select("tasks", where={"id": task_id})[0]["assignee_email"]:
        raise Exception("You can only complete your own tasks")
    update("tasks", where={"id": task_id}, set={"status": "done", "completed_by": user.email})
    return {"ok": True}

@register_function
def delete_task(task_id: str):
    user = get_user()
    if "manager" not in user.roles and user.email != select("tasks", where={"id": task_id})[0]["assignee_email"]:
        raise Exception("You can only delete your own tasks")
    removed = delete_by_id("tasks", id=task_id)
    return {"removed": removed is not None}

@register_function
def clear_done():
    user = get_user()
    removed = delete("tasks", {"assignee_email": user.email, "status": "done"})
    return {"removed": len(removed)}

@register_function
def __render__():
    user = get_user()
    return f"""
<script src="https://cdn.tailwindcss.com"></script>
<style>body{{margin:0;font-family:'Inter',system-ui,sans-serif;background:#f8fafc}}</style>
<div class="min-h-screen p-8 max-w-2xl mx-auto">
  <div class="flex items-center justify-between mb-1">
    <h1 class="text-2xl font-bold text-slate-800">My Tasks</h1>
    <button onclick="clearDone()" class="text-xs font-medium text-slate-500 hover:text-rose-600">Clear done</button>
  </div>
  <p class="text-sm text-slate-400 mt-1 mb-8">Logged in as {user.email} — showing tasks due in the next {UPCOMING_WINDOW_DAYS} days</p>
  <div id="tasks"><div class="flex justify-center py-12"><div class="animate-spin w-6 h-6 border-2 border-indigo-600 border-t-transparent rounded-full"></div></div></div>
</div>
<script>
const priorities = {{ high:'rose', medium:'amber', low:'slate' }};
async function load() {{
  const tasks = await get_my_tasks();
  const el = document.getElementById('tasks');
  if (!tasks.length) {{ el.innerHTML = '<p class="text-center py-16 text-slate-400">All caught up!</p>'; return; }}
  el.innerHTML = '<div class="space-y-3">' + tasks.map(t => {{
    const c = priorities[t.priority] || 'slate';
    return `<div class="bg-white rounded-xl p-5 border border-slate-100 shadow-sm hover:shadow-md transition-all group">
      <div class="flex items-start justify-between">
        <div><span class="text-xs font-medium text-${{c}}-600 uppercase">${{t.priority}}</span>
          <p class="font-medium text-slate-800">${{t.title}}</p>
          <p class="text-xs text-slate-400 mt-1">Due ${{t.due}}</p></div>
        <div class="flex gap-2 opacity-0 group-hover:opacity-100">
          <button onclick="done('${{t.id}}')" class="bg-emerald-50 hover:bg-emerald-100 text-emerald-700 text-sm font-medium px-4 py-2 rounded-lg">Complete</button>
          <button onclick="remove('${{t.id}}')" class="bg-rose-50 hover:bg-rose-100 text-rose-700 text-sm font-medium px-4 py-2 rounded-lg">Delete</button>
        </div>
      </div></div>`;
  }}).join('') + '</div>';
}}
async function done(id) {{ await complete_task(id); load(); }}
async function remove(id) {{ await delete_task(id); load(); }}
async function clearDone() {{ await clear_done(); load(); }}
load();
</script>
"""
```

## Example 3: Form Submission with Database + Task Routing
**Patterns:** HTML form, client-side validation, loading state, `insert()`, `send_task()`

```python
from abstra.pages import register_function, get_user
from abstra.tables import insert
from abstra.tasks import send_task

@register_function
def submit_expense(description: str, amount: float, category: str):
    user = get_user()
    record = insert("expenses", {"description": description, "amount_cents": int(amount * 100),
        "category": category, "submitted_by": user.email, "status": "pending"})
    send_task("expense_submitted", {"expense_id": str(record["id"]), "amount": amount, "submitted_by": user.email})
    return {"id": str(record["id"])}

@register_function
def __render__():
    user = get_user()
    return f"""
<script src="https://cdn.tailwindcss.com"></script>
<style>body{{margin:0;font-family:'Inter',system-ui,sans-serif;background:#f8fafc}}</style>
<div class="min-h-screen flex items-start justify-center pt-16 px-4">
  <div class="w-full max-w-md">
    <h1 class="text-xl font-bold text-slate-800 text-center mb-1">Submit Expense</h1>
    <p class="text-sm text-slate-400 text-center mb-6">{user.email}</p>
    <form id="form" class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100 space-y-5" onsubmit="handleSubmit(event)">
      <div><label class="block text-sm font-medium text-slate-700 mb-1.5">Description</label>
        <input id="desc" type="text" required minlength="3" placeholder="Lunch with client" class="w-full border border-slate-200 rounded-xl px-4 py-2.5 text-sm focus:ring-2 focus:ring-indigo-500 outline-none"></div>
      <div><label class="block text-sm font-medium text-slate-700 mb-1.5">Amount (R$)</label>
        <input id="amount" type="number" required min="0.01" step="0.01" placeholder="150.00" class="w-full border border-slate-200 rounded-xl px-4 py-2.5 text-sm font-mono focus:ring-2 focus:ring-indigo-500 outline-none"></div>
      <div><label class="block text-sm font-medium text-slate-700 mb-1.5">Category</label>
        <select id="cat" required class="w-full border border-slate-200 rounded-xl px-4 py-2.5 text-sm bg-white focus:ring-2 focus:ring-indigo-500 outline-none">
          <option value="">Select...</option><option value="travel">Travel</option><option value="meals">Meals</option>
          <option value="office">Office Supplies</option><option value="software">Software</option></select></div>
      <button type="submit" id="btn" class="w-full bg-indigo-600 hover:bg-indigo-700 text-white py-2.5 rounded-xl font-medium text-sm">Submit Expense</button>
    </form>
    <div id="result" class="mt-4 hidden"><div class="bg-emerald-50 border border-emerald-200 text-emerald-700 p-4 rounded-xl text-sm"><span id="result-text"></span></div></div>
  </div>
</div>
<script>
async function handleSubmit(e) {{
  e.preventDefault();
  const btn = document.getElementById('btn');
  btn.disabled = true; btn.textContent = 'Submitting...'; btn.classList.add('opacity-75');
  try {{
    const r = await submit_expense(document.getElementById('desc').value, parseFloat(document.getElementById('amount').value), document.getElementById('cat').value);
    document.getElementById('result').classList.remove('hidden');
    document.getElementById('result-text').textContent = 'Expense #' + r.id + ' submitted for approval.';
    document.getElementById('form').reset();
  }} catch(err) {{ alert(err.message); }}
  btn.disabled = false; btn.textContent = 'Submit Expense'; btn.classList.remove('opacity-75');
}}
</script>
"""
```

## Example 4: Query Params for Tab Filtering
**Patterns:** `get_query_params()`, server-rendered tab navigation, status badges

```python
from datetime import date, timedelta

from abstra.pages import register_function, get_query_params
from abstra.tables import select, is_gte

RECENT_WINDOW_DAYS = 30

@register_function
def get_orders(status: str):
    cutoff = (date.today() - timedelta(days=RECENT_WINDOW_DAYS)).isoformat()
    where: dict[str, object] = {"created_at": is_gte(cutoff)}
    if status and status != "all":
        where["status"] = status
    # Cap what one screen can show; for tables that can grow, use real
    # server-side pagination (Example 11) instead of a bare cap.
    orders = select("orders", where=where, order_by="created_at", order_desc=True, limit=100)
    return [{"id": o["id"], "customer": o["customer"], "total": o["total_cents"]/100, "status": o["status"]} for o in orders]

@register_function
def __render__():
    params = get_query_params()
    current = params.get("status", "all")
    tabs = [("all","All"),("pending","Pending"),("completed","Completed"),("cancelled","Cancelled")]
    tab_html = "".join(
        f'<a href="?status={val}" class="px-4 py-2 text-sm font-medium rounded-lg '
        f'{"bg-indigo-600 text-white" if current == val else "text-slate-500 hover:bg-slate-100"}">{label}</a>'
        for val, label in tabs)
    return f"""
<script src="https://cdn.tailwindcss.com"></script>
<style>body{{margin:0;font-family:'Inter',system-ui,sans-serif;background:#f8fafc}}</style>
<div class="min-h-screen p-8 max-w-5xl mx-auto">
  <div class="flex items-center justify-between mb-8">
    <h1 class="text-2xl font-bold text-slate-800">Orders</h1>
    <div class="flex gap-1 bg-slate-100 p-1 rounded-xl">{tab_html}</div>
  </div>
  <div id="content" class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
    <div class="flex justify-center py-12"><div class="animate-spin w-6 h-6 border-2 border-indigo-600 border-t-transparent rounded-full"></div></div>
  </div>
</div>
<script>
const fmt = n => new Intl.NumberFormat('pt-BR',{{style:'currency',currency:'BRL'}}).format(n);
const badge = {{pending:'bg-amber-50 text-amber-700',completed:'bg-emerald-50 text-emerald-700',cancelled:'bg-rose-50 text-rose-700'}};
async function load() {{
  const orders = await get_orders("{current}");
  const el = document.getElementById('content');
  if (!orders.length) {{ el.innerHTML = '<p class="text-center py-16 text-slate-400">No orders found</p>'; return; }}
  el.innerHTML = `<table class="w-full text-sm"><thead><tr class="text-left text-xs font-semibold text-slate-400 uppercase border-b border-slate-100">
    <th class="px-6 py-3">Order</th><th class="px-6 py-3">Customer</th><th class="px-6 py-3 text-right">Total</th><th class="px-6 py-3">Status</th>
  </tr></thead><tbody>${{orders.map(o => `<tr class="border-b border-slate-50 hover:bg-slate-50/50">
    <td class="px-6 py-4 font-mono text-xs text-slate-500">#${{o.id.slice(0,8)}}</td>
    <td class="px-6 py-4 font-medium text-slate-700">${{o.customer}}</td>
    <td class="px-6 py-4 text-right font-mono">${{fmt(o.total)}}</td>
    <td class="px-6 py-4"><span class="text-xs font-medium px-2.5 py-1 rounded-full ${{badge[o.status]||'bg-slate-50 text-slate-600'}}">${{o.status}}</span></td>
  </tr>`).join('')}}</tbody></table>`;
}}
load();
</script>
"""
```

## Example 5: Streaming Progress Bar (Generator)
**Patterns:** Generator with `yield`, `for await...of`, real-time progress

```python
import time
from abstra.pages import register_function

@register_function
def run_pipeline():
    steps = ["Connecting to database...", "Fetching records...", "Processing batch 1/3...",
             "Processing batch 2/3...", "Processing batch 3/3...", "Generating report...", "Done!"]
    for i, step in enumerate(steps):
        time.sleep(1)
        yield {"step": step, "progress": round((i + 1) / len(steps) * 100)}

@register_function
def __render__():
    return """
<script src="https://cdn.tailwindcss.com"></script>
<style>body{margin:0;font-family:'Inter',system-ui,sans-serif;background:#f8fafc}</style>
<div class="min-h-screen flex items-center justify-center">
  <div class="w-full max-w-md bg-white rounded-2xl p-8 shadow-sm border border-slate-100">
    <h1 class="text-xl font-bold text-slate-800 mb-6">Data Pipeline</h1>
    <div class="w-full bg-slate-100 rounded-full h-3 overflow-hidden mb-4">
      <div id="bar" class="h-full bg-indigo-600 rounded-full transition-all duration-300" style="width:0%"></div>
    </div>
    <p id="step" class="text-sm text-slate-500 mb-6 h-5"></p>
    <button onclick="start()" id="btn" class="w-full bg-indigo-600 hover:bg-indigo-700 text-white py-2.5 rounded-xl font-medium text-sm">Start Pipeline</button>
  </div>
</div>
<script>
async function start() {
  const btn = document.getElementById('btn');
  btn.disabled = true; btn.textContent = 'Running...'; btn.classList.add('opacity-75');
  for await (const u of run_pipeline()) {
    document.getElementById('bar').style.width = u.progress + '%';
    document.getElementById('step').textContent = u.step;
  }
  btn.disabled = false; btn.textContent = 'Start Pipeline'; btn.classList.remove('opacity-75');
}
</script>
"""
```

**Generator consumption:** `for await...of` (async context), `.forEach(cb)` (any script), `await fn()` (collects array).
## Example 6: Static Files (register_static)
**Patterns:** `register_static()` for JS/CSS/images, separated file structure

`register_static(path)` takes `str`/`os.PathLike`, returns a signed URL. Raises `FileNotFoundError` if missing. Call inside `__render__` so the URL is fresh on every page load.

```python
from abstra.pages import register_function, register_static

@register_function
def get_data():
    return {"count": 42}

@register_function
def __render__():
    js_url = register_static("static/app.js")
    css_url = register_static("static/styles.css")
    logo_url = register_static("assets/logo.png")
    return f"""
<link rel="stylesheet" href="{css_url}">
<script src="https://cdn.tailwindcss.com"></script>
<div class="p-8 max-w-4xl mx-auto">
  <img src="{logo_url}" alt="Logo" class="h-10 mb-4">
  <h1 class="text-2xl font-bold">My Page</h1>
  <div id="content"></div>
</div>
<script src="{js_url}"></script>
"""
```

```js
// static/app.js — registered functions are global, callable from external JS files
async function load() {
    const data = await get_data();
    document.getElementById("content").innerHTML = `<p>${data.count} items</p>`;
}
load();
```

### Separated file structure (recommended for complex pages)
For multi-file pages, use Jinja2 templates. Pass `register_static` URLs as template variables:

```python
# page_dashboard.py — uses lib_jinja.render_template() with Jinja2 FileSystemLoader
from abstra.pages import register_function, register_static
from lib_jinja import render_template

@register_function
def get_stats():
    return {"total": 42}

@register_function
def __render__():
    return render_template("dashboard.html",
        css_url=register_static("static/css/dashboard.css"),
        js_url=register_static("static/js/dashboard.js"))
```

In the template, use `{{ css_url }}` / `{{ js_url }}` in `<link>`/`<script>` tags. Registered functions (like `get_stats`) are global in JS, callable from external `.js` files.
## Example 7: Streaming File Viewer
**Patterns:** Generator for large file streaming, files read from `get_persistent_dir()`, `<script type="module">` for top-level await

```python
from abstra.common import get_persistent_dir
from abstra.pages import register_function

@register_function
def stream_csv():
    with open(get_persistent_dir() / "large_report.csv") as f:
        for line in f:
            yield line.strip()

@register_function
def __render__():
    return """
<script src="https://cdn.tailwindcss.com"></script>
<style>body{margin:0;font-family:'Inter',system-ui,sans-serif;background:#f8fafc}</style>
<div class="min-h-screen p-8 max-w-4xl mx-auto">
  <div class="flex items-center justify-between mb-6">
    <h1 class="text-2xl font-bold text-slate-800">Report Viewer</h1>
    <button id="btn" class="bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium px-5 py-2.5 rounded-lg">Load Report</button>
  </div>
  <div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
    <div class="px-6 py-3 border-b border-slate-100">
      <span class="text-sm text-slate-400">Rows: <span id="count" class="font-mono text-slate-600">0</span></span>
    </div>
    <pre id="output" class="p-6 text-xs font-mono text-slate-600 max-h-[70vh] overflow-auto whitespace-pre"></pre>
  </div>
</div>
<script type="module">
const btn = document.getElementById('btn');
btn.addEventListener('click', async () => {
  btn.disabled = true; btn.textContent = 'Loading...';
  document.getElementById('output').textContent = '';
  let rows = 0;
  for await (const line of stream_csv()) {
    document.getElementById('output').textContent += line + '\\n';
    document.getElementById('count').textContent = ++rows;
  }
  btn.disabled = false; btn.textContent = 'Load Report';
});
</script>
"""
```

## Example 8: File Upload
**Patterns:** Native file upload via generated wrapper, `File`/`Blob` detection, `get_persistent_dir()`, filename sanitization, drag & drop

**Key contract:** When a `File` or `Blob` is passed to a generated wrapper function, the JS automatically switches to `multipart/form-data`. The Python function receives the file as a `dict` with `{"filename": str, "content_type": str, "content": bytes}` — **not** a `Path`, not a file-like object. Always sanitize `filename` before using it in paths.

**Return shape:** Return FileResponse-shaped dicts — `{'name': str, 'path': pathlib.Path}` — the same convention Forms uses for `FileInput`. Serialize `path` as `str(path)` on the wire since JSON can't represent `pathlib.Path` directly.

```python
from pathlib import Path
from abstra.pages import register_function
from abstra.common import get_persistent_dir

UPLOAD_DIR = get_persistent_dir() / "uploads"

@register_function
def upload_file(file: dict):
    UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
    # Always sanitize — filename comes from the client and may contain traversal
    safe_name = Path(file["filename"]).name
    dest = UPLOAD_DIR / safe_name
    dest.write_bytes(file["content"])
    # FileResponse shape: {'name': str, 'path': pathlib.Path}
    return {"name": safe_name, "path": str(dest)}

@register_function
def list_files():
    if not UPLOAD_DIR.exists():
        return []
    return [{"name": f.name, "path": str(f)} for f in sorted(UPLOAD_DIR.iterdir()) if f.is_file()]

@register_function
def __render__():
    return """
<script src="https://cdn.tailwindcss.com"></script>
<style>body{margin:0;font-family:'Inter',system-ui,sans-serif;background:#f8fafc}
.drop-zone{border:2px dashed #cbd5e1;transition:all .2s}.drop-zone.drag-over{border-color:#6366f1;background:#eef2ff}</style>
<div class="min-h-screen p-8 max-w-3xl mx-auto">
  <h1 class="text-2xl font-bold text-slate-800 mb-6">File Upload</h1>
  <div id="dropZone" class="drop-zone bg-white rounded-2xl p-8 text-center mb-6 cursor-pointer">
    <p class="text-slate-600 font-medium mb-1">Click or drag & drop files here</p>
    <p class="text-xs text-slate-400">Any file type</p>
    <input type="file" id="fileInput" class="hidden" multiple>
  </div>
  <div id="status" class="hidden mb-6 text-sm text-slate-500"></div>
  <div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
    <div class="px-6 py-4 border-b border-slate-100 flex justify-between items-center">
      <h2 class="font-semibold text-slate-800">Files</h2>
      <button onclick="refresh()" class="text-sm text-indigo-600 font-medium">Refresh</button>
    </div>
    <div id="fileList"><p class="text-center py-12 text-slate-400">Loading...</p></div>
  </div>
</div>
<script>
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
dropZone.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', e => { if (e.target.files.length) handleFiles(e.target.files); });
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
dropZone.addEventListener('drop', e => { e.preventDefault(); dropZone.classList.remove('drag-over'); if (e.dataTransfer.files.length) handleFiles(e.dataTransfer.files); });

async function handleFiles(files) {
  const status = document.getElementById('status');
  status.classList.remove('hidden');
  // Iterate FileList — call the generated wrapper once per file, passing the File directly
  for (let i = 0; i < files.length; i++) {
    status.textContent = `Uploading ${i + 1} of ${files.length}: ${files[i].name}...`;
    await upload_file(files[i]);   // File detected → automatic multipart
  }
  status.textContent = `Done — ${files.length} file(s) uploaded.`;
  fileInput.value = '';
  refresh();
}

async function refresh() {
  const files = await list_files();
  const el = document.getElementById('fileList');
  if (!files.length) { el.innerHTML = '<p class="text-center py-12 text-slate-400">No files yet</p>'; return; }
  el.innerHTML = '<table class="w-full text-sm"><tbody>' + files.map(f =>
    `<tr class="border-b border-slate-50"><td class="px-6 py-3 font-medium text-slate-700">${f.name}</td><td class="px-6 py-3 text-right text-slate-400 font-mono text-xs">${f.path}</td></tr>`
  ).join('') + '</tbody></table>';
}
refresh();
</script>
"""
```

**Upload anti-patterns to avoid:**
- Do NOT hand-roll `fetch` + `FormData` — the generated wrapper handles multipart automatically when it sees a `File`/`Blob` argument
- Do NOT use `file.name` or `shutil.copy2(file, ...)` — file is a `dict`, not a `Path`
- Do NOT skip filename sanitization — `file["filename"]` is untrusted client input

## Example 9: Shared Sidebar Across Multiple Pages
**Patterns:** Jinja `base.html` with sidebar slot, `lib_components.render_sidebar()`, one Page per section, plain `<a href="/<slug>">` navigation between sections

**Anti-pattern to avoid:** Do NOT build a "main" Page that embeds other Pages in an `<iframe>`. Every Page route already renders inside the project's chrome (navbar, breadcrumbs) — nesting Pages via iframe duplicates that chrome on screen and breaks navigation.

**Right pattern:** Share layout server-side. One `base.html` holds a sidebar slot; each Page extends it and fills its own content block. Navigation between sections is a plain `<a href="/cash-flow">` — each Page's clean public path. NEVER link to `/_page/...`: that's an internal RPC route that serves the bare page without the app shell.

```html
{# templates/base.html — flex layout with sidebar slot #}
<!DOCTYPE html>
<html>
<head>
  <title>{{ title }}</title>
  <script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="flex bg-slate-50 min-h-screen">
  {{ sidebar | safe }}
  <main class="flex-1 p-8">{% block body %}{% endblock %}</main>
</body>
</html>
```

```python
# lib_components.py — shared sidebar, highlights the active section
def render_sidebar(active: str) -> str:
    items = [("dashboard", "Dashboard"), ("cash-flow", "Cash Flow"), ("default-rate", "Default rate")]
    links = "".join(
        f'<a href="/{slug}" class="block p-2 rounded {"bg-slate-700 font-bold" if slug == active else ""}">{label}</a>'
        for slug, label in items
    )
    return f'<aside class="w-56 p-4 bg-slate-900 text-white">{links}</aside>'
```

```html
{# templates/cash_flow.html — one template per section #}
{% extends "base.html" %}
{% block body %}
<h2 class="text-xl font-bold mb-4">Cash Flow</h2>
<div id="cash-flow-chart"></div>
{% endblock %}
```

```python
# page_cash_flow.py — render the template with the sidebar marking this section active
from abstra.pages import register_function
from lib_jinja import render_template
from lib_components import render_sidebar

@register_function
def __render__():
    return render_template(
        "cash_flow.html",
        title="Cash Flow",
        sidebar=render_sidebar(active="cash-flow"),
    )
```

Repeat the template + Python file pair for each section (`page_dashboard.py`, `page_default_rate.py`), passing the matching `active` value. The sidebar is defined once; each Page is its own route — no iframes, one navbar on screen.

**Multi-page anti-patterns to avoid:**
- Do NOT use `<iframe src="/other-page">` to embed another Page — every Page already renders with the project chrome, so the navbar shows up twice
- Do NOT build a single "shell" Page that switches sub-content via JS — you lose URL routing, browser history, and per-section auth
- Do NOT duplicate the sidebar HTML across Page files — render it from one `lib_components.py` helper

## Example 10: AI Document Summarizer (extract_text + prompt)
**Patterns:** file upload → save to disk → `extract_text` → `prompt` on the TEXT, loading state during a slow AI call

For text-heavy documents, OCR the text first with `extract_text`, then `prompt()` on the resulting string — more reliable than passing the file to `prompt()` directly. For Brazilian fiscal docs (NF-e, NFS-e, boleto) prefer the specialized `parse_*` functions; pass files directly to `prompt()` only for visual content (photos, screenshots).

```python
from pathlib import Path

from abstra.ai import extract_text, prompt
from abstra.common import get_persistent_dir
from abstra.pages import register_function

UPLOAD_DIR = get_persistent_dir() / "summarizer_uploads"

@register_function
def summarize_document(file: dict):
    UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
    # Same upload contract as Example 8: file is a dict, filename is untrusted
    safe_name = Path(file["filename"]).name
    dest = UPLOAD_DIR / safe_name
    dest.write_bytes(file["content"])

    # Two-step pattern: extract_text needs a saved file (Path), returns the raw text
    text = extract_text(dest)
    print(f"Extracted {len(text)} characters from {safe_name}")

    summary = prompt([
        "Summarize this document in one short paragraph followed by 3-5 bullet "
        "points with the key facts (amounts, dates, parties, obligations):",
        text,
    ])
    return {"name": safe_name, "summary": summary}

@register_function
def __render__():
    return """
<script src="https://cdn.tailwindcss.com"></script>
<style>body{margin:0;font-family:'Inter',system-ui,sans-serif;background:#f8fafc}</style>
<div class="min-h-screen p-8 max-w-2xl mx-auto">
  <h1 class="text-2xl font-bold text-slate-800 mb-1">Document Summarizer</h1>
  <p class="text-sm text-slate-400 mb-6">Upload a PDF or image — the AI extracts the text and summarizes it.</p>
  <div class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100 mb-6">
    <input type="file" id="fileInput" accept=".pdf,.png,.jpg,.jpeg"
      class="block w-full text-sm text-slate-500 file:mr-4 file:py-2.5 file:px-5 file:rounded-xl file:border-0 file:bg-indigo-50 file:text-indigo-700 file:text-sm file:font-medium hover:file:bg-indigo-100 mb-4">
    <button onclick="summarize()" id="btn" class="w-full bg-indigo-600 hover:bg-indigo-700 text-white py-2.5 rounded-xl font-medium text-sm">Summarize</button>
  </div>
  <div id="result" class="hidden bg-white rounded-2xl p-6 shadow-sm border border-slate-100">
    <h2 id="result-name" class="font-semibold text-slate-800 mb-3"></h2>
    <p id="result-text" class="text-sm text-slate-600 leading-relaxed whitespace-pre-wrap"></p>
  </div>
</div>
<script>
async function summarize() {
  const input = document.getElementById('fileInput');
  if (!input.files.length) { alert('Choose a file first.'); return; }
  const btn = document.getElementById('btn');
  btn.disabled = true; btn.textContent = 'Reading document...'; btn.classList.add('opacity-75');
  try {
    const r = await summarize_document(input.files[0]);  // File detected → automatic multipart
    document.getElementById('result').classList.remove('hidden');
    document.getElementById('result-name').textContent = r.name;
    document.getElementById('result-text').textContent = r.summary;
  } catch (err) { alert(err.message); }
  btn.disabled = false; btn.textContent = 'Summarize'; btn.classList.remove('opacity-75');
}
</script>
"""
```

## Example 11: Paginated Table (server-side pagination)
**Patterns:** `count()` for page math, `select(limit=, offset=)`, page controls, filters combined with pagination — this is the DEFAULT pattern for any table or list in a Page

Rules this example encodes:
- NEVER load the whole table and paginate in the browser: latency and memory grow with the table until the page breaks. With `limit`/`offset` every response stays small no matter how big the table gets.
- Use `count(table, where=...)` for "page X of Y" — it accepts the same filters as `select` and returns an `int`. Do NOT use `run_sql("SELECT COUNT(*) ...")` for this: the raw driver returns the count as a string.
- Apply the same `where` to BOTH `count()` and `select()`, or the page math drifts from the rows shown.

```python
import math

from abstra.pages import register_function
from abstra.tables import count, select

PAGE_SIZE = 25

@register_function
def get_orders_page(page: int, status: str):
    where = {} if not status or status == "all" else {"status": status}
    total = count("orders", where=where)
    pages = max(1, math.ceil(total / PAGE_SIZE))
    page = max(1, min(int(page), pages))
    rows = select(
        "orders",
        where=where,
        order_by="created_at",
        order_desc=True,
        limit=PAGE_SIZE,
        offset=(page - 1) * PAGE_SIZE,
    )
    return {
        "rows": [{"id": r["id"], "customer": r["customer"], "status": r["status"]} for r in rows],
        "page": page,
        "pages": pages,
        "total": total,
    }

@register_function
def __render__():
    return """
<script src="https://cdn.tailwindcss.com"></script>
<style>body{margin:0;font-family:'Inter',system-ui,sans-serif;background:#f8fafc}</style>
<div class="min-h-screen p-8 max-w-5xl mx-auto">
  <div class="flex items-center justify-between mb-6">
    <h1 class="text-2xl font-bold text-slate-800">Orders</h1>
    <select id="status" class="border border-slate-200 rounded-lg px-3 py-2 text-sm bg-white">
      <option value="all">All statuses</option>
      <option value="pending">Pending</option>
      <option value="completed">Completed</option>
    </select>
  </div>
  <div id="content" class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
    <div class="flex justify-center py-12"><div class="animate-spin w-6 h-6 border-2 border-indigo-600 border-t-transparent rounded-full"></div></div>
  </div>
  <div class="flex items-center justify-between mt-4 text-sm text-slate-500">
    <span id="pageinfo"></span>
    <div class="flex gap-2">
      <button id="prev" class="px-3 py-1.5 rounded-lg border border-slate-200 bg-white disabled:opacity-40">Previous</button>
      <button id="next" class="px-3 py-1.5 rounded-lg border border-slate-200 bg-white disabled:opacity-40">Next</button>
    </div>
  </div>
</div>
<script>
let state = { page: 1, status: "all" };

async function load() {
  const data = await get_orders_page(state.page, state.status);
  state.page = data.page;  // server clamps out-of-range pages
  const el = document.getElementById('content');
  el.innerHTML = data.rows.length
    ? `<table class="w-full text-sm"><thead><tr class="text-left text-xs font-semibold text-slate-400 uppercase border-b border-slate-100">
        <th class="px-6 py-3">Order</th><th class="px-6 py-3">Customer</th><th class="px-6 py-3">Status</th>
      </tr></thead><tbody>${data.rows.map(r => `<tr class="border-b border-slate-50 hover:bg-slate-50/50">
        <td class="px-6 py-4 font-mono text-xs text-slate-500">#${r.id.slice(0,8)}</td>
        <td class="px-6 py-4 font-medium text-slate-700">${r.customer}</td>
        <td class="px-6 py-4">${r.status}</td>
      </tr>`).join('')}</tbody></table>`
    : '<p class="text-center py-16 text-slate-400">No orders found</p>';
  document.getElementById('pageinfo').textContent = `Page ${data.page} of ${data.pages} (${data.total} orders)`;
  document.getElementById('prev').disabled = data.page <= 1;
  document.getElementById('next').disabled = data.page >= data.pages;
}

document.getElementById('prev').onclick = () => { state.page--; load(); };
document.getElementById('next').onclick = () => { state.page++; load(); };
document.getElementById('status').onchange = (e) => { state.status = e.target.value; state.page = 1; load(); };
load();
</script>
"""
```

## Example 12: Dashboard over Slow External Data (materialize, don't fetch live)
**Patterns:** scheduled Job syncs a connector into an Abstra Table, Page reads only the table, "last synced" freshness, connector pagination in the sync — the DEFAULT pattern whenever a dashboard shows data from a connector or slow API

Rules this example encodes:
- A Page NEVER calls a connector on page view: each viewer would pay the full API latency on every load (and every tab change), and rate limits break the page for everyone. The Job pays that cost once per sync interval.
- The sync Job paginates through the connector (respect its page size) and replaces the table contents; the Page reads the table with the usual `select`/`count` (paginate the UI as in Example 11).
- Show WHEN the data was last synced — users forgive a 15-minute delay they can see, not stale data that looks live.
- Live connector calls in a Page are reserved for explicit user actions (e.g. a "Sync now" button calling the same sync function).

```python
# job_sync_erp_orders.py — Job stage, scheduled every 15 minutes
from datetime import datetime, timezone

from abstra.connectors import run_connection_action
from abstra.tables import delete, insert, is_not_null

PAGE_SIZE = 100  # respect the connector's page size

def fetch_all_orders():
    page = 1
    while True:
        batch = run_connection_action("erp", "list_orders", {"page": page, "pageSize": PAGE_SIZE})
        yield from batch["items"]
        if page >= batch["totalPages"]:
            break
        page += 1

rows = [
    {
        "external_id": o["id"],
        "customer": o["customer"],
        "total": o["total"],
        "status": o["status"],
        "synced_at": datetime.now(timezone.utc).isoformat(),
    }
    for o in fetch_all_orders()
]

# Full replace keeps the sync idempotent and simple. delete() requires a
# where clause; is_not_null on the key matches every row.
delete("erp_orders", where={"external_id": is_not_null()})
if rows:
    insert("erp_orders", rows)
print(f"synced {len(rows)} orders")
```

```python
# page_orders_dashboard.py — reads ONLY the table; no connector calls here
import math

from abstra.pages import register_function
from abstra.tables import count, select

PAGE_SIZE = 25

@register_function(cache=30)
def get_orders_page(page: int):
    total = count("erp_orders")
    pages = max(1, math.ceil(total / PAGE_SIZE))
    page = max(1, min(int(page), pages))
    rows = select("erp_orders", order_by="synced_at", order_desc=True,
                  limit=PAGE_SIZE, offset=(page - 1) * PAGE_SIZE)
    last_synced = rows[0]["synced_at"] if rows else None
    return {"rows": rows, "page": page, "pages": pages, "total": total, "last_synced": last_synced}

@register_function
def __render__():
    # Static shell only — data arrives via get_orders_page after first paint.
    # Table + pagination controls: same structure as Example 11, plus a
    # "Last synced" line rendered from data.last_synced.
    return """
<script src="https://cdn.tailwindcss.com"></script>
<div class="min-h-screen p-8 max-w-5xl mx-auto">
  <div class="flex items-center justify-between mb-6">
    <h1 class="text-2xl font-bold text-slate-800">ERP Orders</h1>
    <span id="synced" class="text-xs text-slate-400"></span>
  </div>
  <div id="content"></div>
  <div class="flex justify-end gap-2 mt-4">
    <button id="prev" class="px-3 py-1.5 rounded-lg border bg-white disabled:opacity-40">Previous</button>
    <button id="next" class="px-3 py-1.5 rounded-lg border bg-white disabled:opacity-40">Next</button>
  </div>
</div>
<script>
let page = 1;
async function load() {
  const d = await get_orders_page(page);
  page = d.page;
  document.getElementById('synced').textContent = d.last_synced ? `Last synced: ${new Date(d.last_synced).toLocaleString()}` : 'Never synced';
  document.getElementById('content').innerHTML = d.rows.length
    ? `<table class="w-full text-sm bg-white rounded-xl border">${d.rows.map(r =>
        `<tr class="border-b"><td class="px-4 py-3">${r.customer}</td><td class="px-4 py-3">${r.status}</td><td class="px-4 py-3 text-right">${r.total}</td></tr>`).join('')}</table>`
    : '<p class="text-center py-16 text-slate-400">No data yet — the sync job has not run.</p>';
  document.getElementById('prev').disabled = d.page <= 1;
  document.getElementById('next').disabled = d.page >= d.pages;
}
document.getElementById('prev').onclick = () => { page--; load(); };
document.getElementById('next').onclick = () => { page++; load(); };
load();
</script>
"""
```
