Hit ? from anywhere outside a text input to open this help. Esc closes it.
| ? | Toggle this help overlay |
| Esc | Close this overlay / modals |
| ⌘K / Ctrl+K | Toggle command palette (search apps, entities, views) |
| ] | Time-travel: step forward (pauses live) |
| [ | Time-travel: step back |
| } | Step over (skip past sub-call) |
| { | Step out (run to caller's ret) |
| Enter | Submit form / confirm prompt (when an input has focus) |
A web GUI built on a single frame stack. Each frame has its own args, locals, and view. The screen always renders the top frame's view. When that frame rets, control returns to the frame beneath, with its return value handed to the parent's optional resumed(frame, value, api) hook.
api.call(viewName, ...args)arity).locals object.api.ret(value)resumed(frame, value, api) hook with value as the second argument.api.setLocal(key, value)Mutates topFrame().locals[key] and re-renders.
Pass args directly as positional arguments. To call add(a=2, b=3) with args = [2, 3]:
api.call('add', 2, 3); // frame.args = [2, 3]
From an action object (JSON, serializable):
{ op: 'call', view: 'add', args: [2, 3] }
start: main is the only frame
click "CALL prompt"
api.call('prompt', "What's your name?")
prompt frame appears on top
type "Ada", press Enter
api.ret("Ada")
prompt frame popped
main.resumed(frame, "Ada", api) → frame.locals.result = "Ada"
main re-renders showing last result: "Ada"
api.call('add', 3, 2) // frame.args = [3, 2]
add view renders: 3 + 2 = 5
click "RET 5"
add frame popped
main.resumed(frame, 5, api) → frame.locals.result = 5
api.call('add') // missing args
FAULT: add expects 2 args, got 0.
api.call('confirm', "Continue?")
confirm frame appears on top
click "Yes"
api.ret(true)
main.resumed(frame, true, api) → frame.locals.result = true
api.call('getTodo', 1) // frame.args = [1]
enter() fires fetch GET /api/todos/1
frame stays on call stack with locals.status = 'pending'
server responds 200 { id:1, title:"First todo", done:false }
auto api.ret(data)
getTodo frame popped
main.resumed(frame, data, api) → frame.locals.result = {id:1, ...}
api.call('getTodo', 999)
GET /api/todos/999 → 404 { error:"todo 999 not found" }
frame stays on the stack; locals.status = 'error'
view shows FAILED + the error message
click "RETRY" → re-fires the same request
click "RET null" → resumed(frame, null, api)
| view | arity | kind | returns |
|---|---|---|---|
boot | 0 | local | (actual root — checks /api/auth/me, then calls login or main) |
login | 0 | local | user object on success, null on cancel |
main | 0 | local | logout RETs null back to boot |
prompt | 1 | local | entered string, or null if cancelled |
confirm | 1 | local | true or false |
add | 2 | local | numeric sum |
form | 1 (schema) | local | values object, or null if cancelled |
wizard | 1 (steps[]) | local | merged values, or null if cancelled |
getTodo | 1 (id) | remote (GET) | todo object, or null if cancelled / failed |
createTodo | 1 (body) | remote (POST) | created todo, or null if cancelled / failed |
createTodoWizard | 0 | orchestrator | created todo, or null if user cancels at any step |
updateTodo | 2 (id, body) | remote (PUT) | updated todo, or null if cancelled / failed |
editTodo | 1 (id) | orchestrator | updated todo, or null if cancelled at any step |
uploadFile | 1 (File) | remote (POST multipart) | upload metadata {id, name, size, contentType, url}, or null |
pickAndUpload | 0 | orchestrator | upload metadata, or null if user cancels |
Defined in Program.cs. The same Kestrel app serves wwwroot/ static files and the API, so there is no CORS to deal with.
| method | path | body | response |
|---|---|---|---|
| GET | /api/todos | — | Todo[] |
| GET | /api/todos/{id} | — | Todo or 404 {error} |
| POST | /api/todos | { title, priority?, dueDate?, tags?, attachmentId? } | 201 Todo or 400 {error} |
| PUT | /api/todos/{id} | { title, done, priority?, dueDate?, tags?, attachmentId? } | 200 Todo, 404 {error}, or 400 {error} |
| POST | /api/uploads | multipart/form-data with file part; requires X-CSRF-TOKEN header | 201 {id, name, size, contentType, url} or 400 {errors} |
| GET | /api/uploads/{id} | — | raw file bytes with the original Content-Type; 404 if missing or not yours |
| POST | /api/auth/login | { username, password } | 200 { id, username } + sets auth cookie, or 400 {errors} |
| POST | /api/auth/logout | — | 204 + clears auth cookie |
| GET | /api/auth/me | — | 200 { id, username } or 401 |
| GET | /api/csrf | — | 200 { token } for the X-CSRF-TOKEN header on multipart uploads |
All /api/todos* and /api/uploads* endpoints require an authenticated session via the cookie auth scheme. The seeded demo user is admin/admin — change in Program.cs.
To run: dotnet run from the project root, then open http://localhost:5050.
The form view turns a multi-field input flow into a single CALL. api.call('form', schema) with an array of field descriptors; the form RETs a values object on submit, or null on cancel.
[
{ name: 'title', label: 'Title', type: 'text', default: '', required: true },
{ name: 'priority', label: 'Priority', type: 'select', default: 'med',
options: [
{ value: 'low', label: 'Low' },
{ value: 'med', label: 'Medium' },
{ value: 'high', label: 'High' },
] },
{ name: 'count', label: 'Count', type: 'number', default: 1 },
{ name: 'done', label: 'Done', type: 'checkbox', default: false },
{ name: 'notes', label: 'Notes', type: 'textarea', default: '', rows: 4 },
]
| type | renders as | value type | notes |
|---|---|---|---|
text (default) | <input type="text"> | string | also handles email, password, etc. |
number | <input type="number"> | number or null | empty input → null |
checkbox | <input type="checkbox"> | boolean | required: true means must be checked |
select | <select> | string (option's value) | provide options: [{value, label}] |
textarea | <textarea> | string | Enter inserts a newline (does not submit) |
date | <input type="date"> | ISO string YYYY-MM-DD or '' | browser-native date picker |
file | <input type="file"> | File (or File[] if multiple: true) | optional accept: 'image/*'; selection clears on re-render — see Limitations |
array | list of inputs + Add/Remove buttons | array of items | provide itemType: 'text' | 'number', optional addLabel; required: true means non-empty |
visible)Any field can declare visible: (values) => boolean. The form re-renders on change events whenever any field has a visibility predicate. Hidden fields are skipped during validation but their values are preserved (so toggling visibility back doesn't lose what the user typed).
{ name: 'hasDueDate', label: 'Has due date', type: 'checkbox', default: false },
{ name: 'dueDate', label: 'Due date', type: 'date', required: true,
visible: (v) => v.hasDueDate === true },
Validation runs on submit (Next/Submit in a wizard, Submit in a form). Hidden fields are skipped. The first failing rule wins per field; the form re-renders with the error inline and focus jumps to the first errored field.
| rule | applies to | default message |
|---|---|---|
required: true | any field; checkbox must be checked, array/file non-empty | "required" |
minLength: N | string fields (text/textarea/email/password) | "must be at least N characters" |
maxLength: N | string fields | "must be at most N characters" |
min: N | number | "must be ≥ N" |
max: N | number | "must be ≤ N" |
pattern: /regex/ | string fields | "invalid format" |
minItems: N | array | "at least N items required" |
maxItems: N | array | "at most N items" |
Override messages per rule via a messages map:
{ name: 'username', label: 'Username', type: 'text', required: true,
minLength: 3, maxLength: 32,
pattern: /^[a-z][a-z0-9_]*$/,
messages: {
required: 'username is required',
minLength: 'must be at least 3 characters',
pattern: 'lowercase letters / digits / underscores only',
} }
For anything the declarative rules can't express (cross-field, async-after-the-fact, regex you'd rather write as code), add validate(value, allValues) => string | null. Runs after the declarative rules pass. Return a string to fail, return null/undefined to pass.
{ name: 'confirmPassword', type: 'password', required: true,
validate: (v, all) => v !== all.password ? 'passwords do not match' : null }
The second arg is the full values object, so the function sees what every other field currently holds — useful for "field A must equal/exceed field B" rules.
If a field is not required and the user leaves it empty, all rules (including pattern, minLength, etc.) are skipped — empty stays valid. Use required: true to force a value before any rules apply.
The server returns 400 responses with a structured shape:
{ "errors": { "title": "must be at least 3 characters", "priority": "..." } }
fetchJSON / fetchMultipart parse data.errors into err.fieldErrors on the thrown error. runRequest intercepts that case and auto-RETs a sentinel so callers can re-show the form:
{ kind: 'fieldErrors', fieldErrors: { title: '...', priority: '...' }, message: '...' }
Use isFieldErrorsResult(v) to detect, then mergeErrorsIntoSchema(schema, errors, values) to produce a copy of the schema with each errored field's error set and default set to the user's last value. The caller re-CALLs form (or wizard, with errors enriched into each step) and the user sees the errors inline. They submit again, the request fires, repeat until valid.
click "CALL editTodo (id 1)"
getTodo → form (prefilled with "First todo")
change title to "ab" (2 chars; client allows because schema only says required:true)
click "RET values"
updateTodo: PUT /api/todos/1 { title: "ab", ... }
server returns 400 { errors: { title: "must be at least 3 characters" } }
fetchJSON throws with err.fieldErrors
runRequest auto-RETs { kind: 'fieldErrors', fieldErrors: {title: "..."}, message: "..." }
editTodo.resumed (step='save') sees the sentinel → re-CALLs form with
schema enriched: title field gets error="must be at least 3 characters",
default="ab" so the user sees what they submitted.
fix title, click "RET values" again
updateTodo succeeds; updated todo bubbles to main.
Other failures (network errors, 500s, 404s, anything without fieldErrors) still go to the FAILED state with RETRY/RET null. Only structured 400s auto-RET — by design, since those are the cases the caller can do something useful with.
{values}.null immediately, no validation.click "CALL form (multi-field)"
main pushes the schema array
CALL form (arity 1) pops it as args[0]
form initialises values from each field's default
fill in fields, leave Title blank, click "RET values"
client validation fails → errors.title = 'required'
form re-renders with the error, focus on Title
type "Hello", press Enter
validation passes → RET {title:"Hello", priority:"med", count:1, done:false, notes:""}
main.resumed pops it into locals.result
// inside an orchestrator view that calls form, then POSTs:
resumed(frame, values) {
if (values === null) return api.ret(null);
fetchJSON('POST', '/api/todos', values)
.then(created => api.ret(created))
.catch(e => {
if (e.status === 400 && e.data?.fields) {
// re-show form with server errors mapped onto schema
const next = baseSchema.map(f => ({
...f,
default: values[f.name],
error: e.data.fields[f.name],
}));
api.call('form', next);
} else { throw e; }
});
}
The wizard view chains multiple form-like steps into one CALL. Arg is an array of {title, schema} entries; the wizard is a single frame whose locals.stepIndex tracks the active step. Back/Next navigate between steps, Submit on the last step RETs the merged values, Cancel RETs null.
api.call('wizard', [
{ title: 'Account', schema: [
{ name: 'username', label: 'Username', type: 'text', required: true },
{ name: 'email', label: 'Email', type: 'email', required: true },
]},
{ title: 'Profile', schema: [
{ name: 'wantsBio', label: 'Add a bio', type: 'checkbox', default: false },
{ name: 'bio', label: 'Bio', type: 'textarea',
visible: (v) => v.wantsBio === true },
]},
{ title: 'Confirm', schema: [
{ name: 'agree', label: 'I agree', type: 'checkbox', required: true },
]},
]);
// later, in resumed: values = { username, email, wantsBio, bio, agree }
null — the wizard does not unwind partial values.locals.stepIndex, visible in the call-stack debug pane.Two extension points let a wizard branch on earlier answers:
step.visible(values) — predicate. When false, the step is skipped on Next/Back, omitted from the dot indicator, and excluded from "Step X of Y".step.schema as a function — instead of a fixed array, pass (values) => fieldDescriptor[]. Re-evaluated on every render, so it can swap fields based on earlier answers.Defaults for fields in static-schema steps are seeded at enter(); defaults for fields in dynamic-schema steps are seeded the first time that field appears. Existing values are never clobbered — if a field disappears (visibility flip or dynamic schema drops it) and later reappears, the user's previous input is preserved.
api.call('wizard', [
{ title: 'Account type', schema: [
{ name: 'kind', label: 'Account type', type: 'select', default: 'individual',
options: [
{ value: 'individual', label: 'Individual' },
{ value: 'business', label: 'Business' },
] },
]},
// Conditional whole step — skipped entirely when kind !== 'business'.
{ title: 'Business info', visible: (v) => v.kind === 'business', schema: [
{ name: 'companyName', label: 'Company name', type: 'text', required: true },
{ name: 'taxId', label: 'Tax ID', type: 'text', required: true },
]},
// Dynamic schema — different fields based on the earlier selection.
{ title: 'Contact', schema: (v) => v.kind === 'business'
? [
{ name: 'contactName', label: 'Primary contact name', type: 'text', required: true },
{ name: 'contactEmail', label: 'Contact email', type: 'email', required: true },
]
: [
{ name: 'firstName', label: 'First name', type: 'text', required: true },
{ name: 'lastName', label: 'Last name', type: 'text', required: true },
{ name: 'email', label: 'Email', type: 'email', required: true },
]
},
{ title: 'Confirm', schema: [
{ name: 'agree', label: 'I agree to the terms', type: 'checkbox', required: true },
]},
]);
What you'll see:
contactName; switching back surfaces it again.An orchestrator view chains multiple CALLs across resumed() hooks. State is tracked in locals.step so a single view can drive a multi-step async flow. editTodo is the canonical example:
defineView('editTodo', {
arity: 1,
enter(frame) {
frame.locals.id = frame.args[0];
frame.locals.step = 'fetch';
api.call('getTodo', frame.locals.id);
},
resumed(frame, value) {
if (frame.locals.step === 'fetch') {
const todo = value;
if (!todo) { api.ret(null); return; }
frame.locals.todo = todo;
frame.locals.step = 'edit';
api.call('form', [ // schema prefilled from todo
{ name: 'title', type: 'text', default: todo.title, required: true },
{ name: 'done', type: 'checkbox', default: todo.done },
]);
} else if (frame.locals.step === 'edit') {
const values = value;
if (values === null) { api.ret(null); return; }
frame.locals.step = 'save';
api.call('updateTodo', frame.locals.id, values);
} else if (frame.locals.step === 'save') {
api.ret(value); // bubble updated todo to caller
}
},
...
});
While editTodo is on the stack, you'll see four frames during the edit step: main → editTodo → form → (nothing, form is top), and during the save step: main → editTodo → updateTodo.
click "CALL editTodo (id 1)"
main pushes 1
editTodo enter(): step='fetch', push id, call getTodo
call stack: main → editTodo → getTodo (pending)
server returns 200 { id:1, title:"...", done:false }
getTodo auto-RETs todo; editTodo.resumed runs (step='fetch')
step='edit', push schema with todo's defaults, call form
call stack: main → editTodo → form
fill in, click "RET values"
form RETs {title, done}; editTodo.resumed (step='edit')
step='save', push id, push values, call updateTodo
call stack: main → editTodo → updateTodo (pending)
server returns 200 with updated todo
updateTodo auto-RETs; editTodo.resumed (step='save')
api.ret(value) — updated todo bubbles to main
main.resumed pops it → locals.result
This orchestrator chains wizard (3 steps) → optional uploadFile → createTodo, demonstrating a real CRUD-with-attachment flow built from the primitives.
click "CALL createTodoWizard"
enter(): step='collect', push 3-step schema, call wizard
call stack: main → createTodoWizard → wizard
step 1 — Basics : title (required), priority (low/med/high)
step 2 — Scheduling : hasDueDate (checkbox), dueDate (visible if checked, required), tags (array)
step 3 — Attachment : optional file picker
click SUBMIT on step 3
wizard RETs merged values; createTodoWizard.resumed (step='collect')
branch:
if values.attachment is a File:
step='upload', push file, call uploadFile
call stack: main → createTodoWizard → uploadFile (pending)
→ on success, attachmentId := upload.id
step='create', push body, call createTodo
else:
step='create' directly, push body, call createTodo
call stack: main → createTodoWizard → createTodo (pending)
server returns 201 with the new todo (priority, dueDate, tags, attachmentId all populated)
createTodo auto-RETs; createTodoWizard.resumed (step='create')
api.ret(value) — todo bubbles to main
type Todo = {
id: number,
title: string,
done: boolean,
priority: 'low' | 'med' | 'high',
dueDate: string | null, // ISO yyyy-MM-dd
tags: string[],
attachmentId: string | null, // GUID; references /api/uploads/{id}
}
Server validates: title non-blank, priority ∈ {low, med, high} (defaults to med), dueDate matches yyyy-MM-dd if present, attachmentId must reference an existing upload (else 400).
Uploads use the same syscall pattern as other remote views, but with a multipart body instead of JSON. fetchMultipart(method, url, formData) wraps fetch() and lets the browser set the Content-Type (including the boundary) automatically.
defineView('uploadFile', {
arity: 1, // a File from a form's file field
enter(frame) {
const file = frame.args[0];
if (!(file instanceof File)) {
throw new Error('uploadFile: arg must be a File');
}
frame.locals.name = file.name;
frame.locals.size = file.size;
runRequest(frame, () => {
const fd = new FormData();
fd.append('file', file); // server reads form.Files['file']
return fetchMultipart('POST', '/api/uploads', fd);
});
},
render(frame, api, root) {
renderRequestFrame(frame, root,
`POST /api/uploads (${frame.locals.name}, ${frame.locals.size} bytes)`);
},
});
The pickAndUpload orchestrator pairs the existing form view (single file field) with uploadFile:
main → pickAndUpload → form (user chooses a file, RETs {file: File})
→ pickAndUpload → uploadFile (POSTs multipart, RETs metadata)
→ main.resumed pops the upload metadata into locals.result
The server saves bytes to uploads/<guid> on disk and remembers metadata in an in-memory Dictionary<Guid, UploadedFile>. GET /api/uploads/{id} streams the file back with the original Content-Type, so an <img src="/api/uploads/..."> displays inline.
If the uploaded file is an image, main renders an inline preview below the JSON metadata so you can see the round trip.
click "CALL pickAndUpload"
pickAndUpload enter(): step='pick', push file-field schema, call form
call stack: main → pickAndUpload → form
choose a file, click "RET values"
form RETs {file: File}; pickAndUpload.resumed (step='pick')
step='upload', push file, call uploadFile
call stack: main → pickAndUpload → uploadFile (pending)
server returns 201 with {id, name, size, contentType, url}
uploadFile auto-RETs metadata; pickAndUpload.resumed (step='upload')
api.ret(value) — metadata bubbles to main
main.resumed → locals.result; if image, main renders <img> preview
A remote view is just a regular view whose enter() fires an HTTP request via fetchJSON and auto-RETs the response. The in-flight request lives as a frame on the call stack — visible in the debug pane — so async network calls feel like ordinary CALLs.
defineView('getTodo', {
arity: 1,
enter(frame) {
frame.locals.id = frame.args[0];
runRequest(frame, () => fetchJSON('GET', `/api/todos/${frame.locals.id}`));
},
render(frame, api, root) {
renderRequestFrame(frame, root, `GET /api/todos/${frame.locals.id}`);
},
});
runRequest tracks three states in frame.locals.status: 'pending', 'ok' (auto-RETs immediately), or 'error' (offers RETRY / RET null). The retry closure is stashed at frame.locals._request — function-typed locals are filtered out of the debug pane so they don't clutter it.
viewName(arg1, arg2, ...) with a locals: sub-line. Top frame is highlighted; that's the one rendering in the main pane. Section starts collapsed — click the heading to open.In app.js:
defineView('myView', {
arity: 1,
enter(frame) {
frame.locals.input = frame.args[0];
},
render(frame, api, root) {
root.innerHTML = `
<p>You said: ${frame.locals.input}</p>
<button id="done">RET</button>
`;
root.querySelector('#done').onclick = () => api.ret('done');
},
resumed(frame, value) {
// called when a callee RETs back to me; `value` is what they returned
frame.locals.lastChild = value;
},
});
Hook lifecycle:
enter(frame, api) — runs once when the frame is pushed.render(frame, api, root) — runs every time the machine state changes and this frame is on top.resumed(frame, value, api) — runs after a callee RETs, before the next render. value is the callee's return value.api.call(viewName, ...args) invoke another view; args land on frame.args
api.ret(value) return to caller (delivered to its resumed)
api.setLocal(k, v) set a local on the top frame and re-render
fetchJSON(method, url, body, options?)
thin wrapper over fetch() — JSON in, JSON out.
Throws on non-2xx with err.status / err.data;
400 with { errors: {...} } populates err.fieldErrors.
options.signal is forwarded to fetch().
fetchMultipart(method, url, formData, options?)
same shape but sends a FormData body — for uploads.
The strict call-stack model is the source of both the clarity and the constraints. Things this design intentionally does not (yet) do:
stepIndex, but they're a single frame.)resumed() branches on locals.step. There's no await-style sugar for "call this then this then this".text or number only — no array of nested objects, no array-of-arrays. Add a sub-form-per-item type if you need that.locals.values[name] still holds the File object. The renderField shows a "selected: filename" line as a workaround.data-array-item attributes and don't currently round-trip through the focus restore path.null regardless of which step the user reached. There's no "save draft" mid-wizard.AbortController.abort() on the active fetch, then RETs null. The server may still complete the work if the request already arrived (HTTP semantics), but the response is ignored and the connection is torn down.runRequest(frame, makeRequest, { timeoutMs: 60000 }).locals doesn't survive a re-render. If an input isn't backed by a local, its value disappears when something else triggers render. The prompt view restores cursor + value via locals.draft; new views must do the same if they care.innerHTML replace. No diffing, no virtual DOM. Fine here; would need rethinking for very large views.args — calling add with strings produces NaN, not a fault.arity is the only static contract. Names, types, and order of args live only in the view's source.EnsureCreated; data lives in data.db at the project root. Real production should use dotnet ef migrations add + Database.MigrateAsync instead, so schema changes don't require dropping the file.file.size and file.type to fail fast without a round trip. No virus scanning.GET /api/csrf and attaches it as X-CSRF-TOKEN on multipart POSTs. Token is cached and refreshed lazily, cleared on login/logout, retried once if a stale token is rejected.HttpOnly + SameSite=Lax. No registration UI in v1 — change/add users by editing Program.cs's seed block. Passwords hashed with PBKDF2/SHA256 at 100,000 iterations.uploads/<guid> and the metadata row stays in Uploads. Deleting a todo with an attachment doesn't clean up either; the demo has no delete endpoint anyway.