A page is composed from a closed vocabulary: the containers you met in the
layout section, the HTML elements, and a set of primitives that each add a
capability nothing else composes — an input bound to state, a disclosure,
a layer, an icon. Anything else you want is a view you write from these.
This chapter walks the primitives with the test harness, so every claim is
checked without a browser.
A bound input has no value of its own. The reader's change lands in the variable, handlers read the variable, and the next render draws it. Every kind of input binds the same way:
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
use web, ui
name = 'Ann'
qty = 100
notes = ''
agree = false
notify = true
side = 'buy'
mode = 'Practice'
pct = 0.4
view form[]
return stack[
tabs[['Scanner Replay', 'Practice'], mode],
row[field['Name', name], field['Qty', qty], gap = 'm'],
field['Notes', notes, lines = 3],
row[checkbox['I agree', agree], toggle['Notify', notify], radio[['buy', 'sell'], side], gap = 'l'],
progress[pct],
gap = 'm'
]
test inputs_bind_to_state[]
a = app[form]
h = html[a]
assert['aria-selected="true" role="tab"' in h]
assert['<input aria-label="Qty" data-crow-input="__field:qty" type="number" value="100">' in h]
assert['<textarea aria-label="Notes" data-crow-input="__field:notes" rows="3">' in h]
assert['crow-toggle"><input checked="" data-crow-input="__field:notify" type="checkbox">' in h]
assert['<progress class="crow-progress" max="1" value="0.4">' in h]
page['/', form]field looks at the variable: an int or float gets a number input, and
type = 'date' or 'time' asks for the browser's picker. lines = 3 makes
a textarea. tabs and radio hold one of several strings, checkbox and
toggle a bool — the same binding drawn two ways.
details shows its summary always and its body when open. A dropdown is
a button whose second child floats under it. The three layers — modal,
drawer, toast — sit outside the flow and show while open is true. Open
is state, so a handler opens and closes them:
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
use web, ui
show_help = false
saved = false
fn open_help[]
show_help = true
fn close_help[]
show_help = false
fn save[]
saved = true
view layers[]
return stack[
details[summary['More filters'], p['Min price, max float'], open = true],
dropdown[summary['Actions'], stack[button['Help', click = open_help], button['Save', click = save]]],
modal[stack[h3['Help'], p['Click a bar to seek.'], button['Close', click = close_help]], open = show_help],
toast[p['Saved'], open = saved, color = 'up'],
divider[],
gap = 'm'
]
test layers_follow_state[]
a = app[layers]
assert['class="modal" data-open="false"' in html[a]]
click[a, open_help]
assert['class="modal" data-open="true"' in html[a]]
click[a, close_help]
click[a, save]
assert['class="toast" data-open="true"' in html[a]]
page['/', layers]nav marks the current page, icon draws from a closed set in the text
colour, tip puts the browser's tooltip on anything, and divider rules
between siblings:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
use web, ui
view top_bar[]
return stack[
nav[a['Home', href = '/'], a['Scanner', href = '/scanner'], active = '/scanner'],
row[icon['search'], span['Search the tape', tip = 'type a symbol'], icon['star', color = 'accent'], gap = 's'],
gap = 'm'
]
test the_shell[]
a = app[top_bar]
h = html[a]
assert['<a aria-current="page" href="/scanner">' in h]
assert['<svg aria-hidden="true" class="icon icon-search"' in h]
assert['title="type a symbol"' in h]
assert[len[box[a, 'nav a']] eq 2]
page['/', top_bar]A page shows many of something: past, bookmarks, trades. A for
expression over a view gives a list of nodes, and a child that is a list
contributes each item. That is the whole rule — no loop element, no
template syntax:
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
use web, ui
past = ['HSCS 2026-06-23', 'EZGO 2026-06-17', 'ANY 2026-06-01']
starting = true
fn forget[]
past = ['HSCS 2026-06-23']
starting = false
view session_row[s]
return row[span[s], button[icon['x', size = 's'], click = forget, tip = 'delete'], justify = 'between']
view past_sessions[]
rows = for s in past
session_row[s]
return stack[h2['Past past'], rows, button['Start', disabled = starting, class = 'primary'], gap = 's']
test a_list_is_its_items[]
a = app[past_sessions]
assert[len[elements[a, 'row']] eq 3]
assert['disabled=""' in html[a]]
click[a, forget]
assert[len[elements[a, 'row']] eq 1]
assert['disabled=""' not in html[a]]
page['/', past_sessions]The same rule gives every row its own controls — a delete icon, a pin,
a link — which table[frame] cannot, since a frame's cells are values.
Use the table for data and a list of views for rows that act.
disabled = starting shows the other half: a boolean attribute follows
state. HTML reads presence, not the word, so the renderers write
disabled="" when the value is true and nothing when it is false — the
same for hidden, readonly, required, checked and selected.
Theme, density, accent and width are settings of the app, not of the
elements. They go on serve:
1 2 3 4 5 6 7
use web, ui
view render[]
return stack[h1['Terminal'], p['dense, dark, full width']]
page['/', render]
serve[3000, theme = 'dark', density = 'compact', accent = '#3b82f6', wide = true]theme is 'light', 'dark', or 'system' (the default). density = 'compact' tightens every element's defaults for a screen full of numbers.
accent replaces the one colour the tokens leave to you. wide drops the
reading column so a dashboard uses the whole window. Nothing in the views
changes: the same tree renders under every look, and the layout engine
places it the same way.
A list of records usually wants a button or two per row. table takes
them as actions, a map from label to handler; each button sends the
row's key to its handler, as a row click does. hide keeps a helper
column in the frame but out of the table, and a header bar is header.
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
use web, ui, frame
deleted = 0
opened = 0
fn open_row[id]
opened = id
fn delete_row[id]
deleted = id
view records[]
rows = frame.new[]
rows = with[rows, 'id', [3, 4]]
rows = with[rows, 'name', ['alpha', 'beta']]
return stack[
header[h1['records', size = 'm'], nav[a['Home', href = '/'], active = '/'], gap = 'm'],
table[rows, click = open_row, key = 'id', actions = map[Delete = delete_row], hide = ['id']]
]
test each_row_carries_its_actions[]
a = app[records]
h = html[a]
assert['<header' in h]
assert['<button data-crow-args="[3]" data-crow-click="delete_row">Delete</button>' in h]
assert['<th class="num">id</th>' not in h]
click[a, delete_row[4]]
assert[deleted eq 4]
page['/', records]The vocabulary is closed on purpose, which means a map, a rich-text
editor or a calendar cannot be written with it. Those are widgets: a
piece of the page your own JavaScript draws. widget['map', props = …, on = map[pick = choose]] draws the module widgets/map.js in your
program's folder. The module exports two functions:
// widgets/map.js
export function mount(el, props, send) {
const b = document.createElement('button');
b.textContent = 'Pick Paris (showing ' + props.city + ')';
b.onclick = () => send('pick', 'Paris');
el.appendChild(b);
}
export function update(el, props) {
el.querySelector('button').textContent = 'Pick Paris (showing ' + props.city + ')';
}
The page loads the module once and calls mount with the element and
the props, as JSON. Whenever a render changes the props it calls
update, and the element's insides stay the module's. send('pick', value) runs the handler that on names for pick, the way a click
would: the page offers exactly the events its widgets name, and the
handler receives the value, so it checks what it is given. In a test a
widget is its element, and a click on the handler stands for what the
widget would send:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
use web, ui
place = 'London'
fn choose[city]
place = city
view atlas[]
return stack[
p['showing `place`'],
widget['map', props = map[city = place], on = map[pick = choose]]
]
test the_map_s_pick_moves_the_page[]
a = app[atlas]
click[a, choose['Paris']]
assert[elements[a, 'p'][0].text eq 'showing Paris']
assert[elements[a, 'div.crow-widget'][0]['data-crow-props'] eq '{"city":"Paris"}']
page['/', atlas]
log['atlas defined']expected output — from the book, verified by crow test --book
atlas definedThere is no other way to put code on a page: no script tags, no inline handlers.