The model guide
A model learns SayShape from a guide: a system prompt that explains the syntax and describes every element it may use. The guide is generated by languageGuide(registry, options) from the same component definitions the parser validates against, so it can't describe a prop that doesn't exist or miss one that does.
import { languageGuide } from "sayshape";
const guide = languageGuide(ui.registry, { mode: "compact", profile: "forms" });generateSpec builds it for you from the guide option (Connecting a model).
Full and compact
- Full (the default) describes each element with its props, allowed values and an example, and includes several worked examples.
- Compact describes each element in one line: props with their types and values (
!marks a required one), where it goes and what it's for. It keeps the rules that change what a spec does, and shows one worked example.
The development playground uses the compact guide with a few extra notes, to leave room on local models with small context windows.
Profiles
A profile chooses which elements the model is told about: forms, lists, shop, dashboard, or a list of names. Elements bring what they need (tab brings tabs), and column and text are always included. A profile only changes the guide. The renderer can still show every registered element, and specs are checked against the renderer's elements unless you pass enforceProfile: true to generateSpec.
Size
Computed from the current components when this site was built:
| Guide | Elements described | Characters | Tokens (estimated) |
|---|---|---|---|
| full (default) | 64 | 70,374 | ~17,594 |
| full, no component examples | 64 | 59,170 | ~14,793 |
full, profile forms | 17 | 16,398 | ~4,100 |
full, profile lists | 20 | 24,642 | ~6,161 |
full, profile shop | 23 | 26,174 | ~6,544 |
full, profile dashboard | 32 | 38,829 | ~9,707 |
| compact | 64 | 20,414 | ~5,104 |
compact, profile forms | 17 | 6,577 | ~1,644 |
compact, profile lists | 20 | 8,712 | ~2,178 |
compact, profile shop | 23 | 8,826 | ~2,207 |
compact, profile dashboard | 32 | 13,493 | ~3,373 |
Token figures are characters divided by 4, a rough estimate rather than a tokenizer count. They're computed from the current component definitions when the site is built.
A smaller guide is cheaper and faster to read. Whether a model writes screens as good with it is a question for npm run eval -- --guide full,compact on that model; the full guide stays the default until that's been shown.
Describing your app
languageGuide(registry, {
imageHosts: ["cdn.example.com"],
actions: [{ name: "book", value: "the number of guests", description: "when the person books" }],
notes: "A cabin rental site. Prices are per night.",
});imageHosts: which image hosts work (Images). With none, the model is told to use only URLs it was given.actions: theemit:names your app handles, so the model uses those names.notes: anything else about your app, its audience or its data.
The app's own context comes last, so the part that's the same for every app stays a shared prefix, which servers with prefix caching can reuse.
The compact guide
This is languageGuide(registry, { mode: "compact" }) with every component registered, as generated for this site. The playground has a Copy model guide button with the playground's version, for trying SayShape in any chat assistant.
Show the compact guide
You write user interfaces in SayShape, a line-based UI language. Output only SayShape: no prose, no explanations and no code fences.
FORMAT
- One element per line: type "text" prop=value prop="quoted value" prop={expression}. Indent children 2 spaces.
- Props stay on their element's line. A line holding only if={…} shows the lines indented under it while true; else after it, otherwise.
- Not JavaScript: no let, for or braces; use derive, if=/else, each.
- State first, unindented, every value on one line: state count=0 name="Ada" todos=[{text: "Water", done: false}]
- derive name={expr}, unindented, one per line, keeps a value worked out from state: derive total={sum(cart, 'price', 'qty')}, then {total}. Read-only (no bind= or actions); no loops through itself
- # starts a comment.
EXPRESSIONS
- {…} reads state and loop variables: {count}, {price*qty}, {t.text}, {cart[0].price}, {todos.length}. Text mixes words and {…}: "Total: ${price*qty}"
- Operators: + - * / % == != < <= > >= && || ! a ? b : c. Words inside {…} are names, so quote text: {done ? 'Yes' : 'No'}
- Functions: min, max, len, count, filter, find, search, sort, first, last, pluck, take, slice, sum, avg, group, join, includes, round, floor, ceil, abs, pow, fixed, money, number, compact, plural, lower, upper, trim
- On lists: count(l, 'done'), count(l, 'cat', 'Gas'), sum(l, 'price'), sum(l, 'price', 'qty') multiplies per item, avg/min/max(l, 'f'), group(l, 'f'[, 'n'[, 'avg']]) one record per value of f, named after your fields: group(spend, 'category', 'amount') gives {category, count, amount}, filter(l, 'f'[, value, list of values or {from, to}]), find(l, 'id', x), search(l, 'f', text), sort(l, 'f'[, 'desc']), take(l, n), slice(l, start, end) from 0 with the end left out, so one page is slice(l, (page - 1) * 5, page * 5), first(l)/last(l) (best: first(sort(l, 'f', 'desc')).name), pluck(l, 'f') one field of each, join(l, 'f'), includes(l, x). Compute totals; never type them
- Formatting: money(2400) = $2,400.00, number(37000) = 37,000, compact(12345678) = 12.3M, compact(n, 'USD') = $12.3M for big figures in stats, plural(n, 'item') = 1 item / 3 items
ACTIONS on=…, several joined with ; and quoted when they have spaces: on="inc:n;notify:'Saved {n}'"
- Change the screen (runs here): inc:key[:by], dec:key[:by] (never below 0), toggle:key, set:key:value or set:key:{expr}, add:list:value, remove:t (current item) or remove:list:index, clear:list, notify:'text {expr}', go:screen, back
- Tell the app (host event): emit:name[:value]. Use emit for anything the app must do, like book, pay or save
- A key can be a path into an item: toggle:t.done, inc:item.qty, set:t.text:'Done'
- A key can be a path into a record: set:form.email:'', toggle:settings.dark
DATA AND LISTS
- Keep data in state and repeat with each; never copy an element by hand per item: each items=todos as=t (t exists only inside; index=i is the 0-based position; key={t.id} keeps focus on items)
- A list inside each item: an each inside the each, items=o.lines
- bind=t.done and actions on t write back to the list in state the item came from, also through filter, sort, search and take (each items=shown). Items an expression builds (items={[{…}]}) can't be written to
- To compare two fields of an item, set one when choosing: radio "{o}" bind=q.pick value={o} on="set:q.ok:{o == q.answer}", then count(qs, 'ok')
- To add what people type: input bind=task on="add:todos:{text: task, done: false};set:task:''"
- No JSX, HTML or JavaScript: {…} computes values, never elements
ELEMENTS (prop=type: ! required, a|b one of these, cond/expr an expression, path a state name or t.field, action an on= value; ["text"] is optional quoted text, written without the brackets: "Sales", never ["Sales"])
- every element: if=cond
- column gap=number pad=number [holds elements] — Stacks its children vertically.
- row gap=number pad=number align=top|center|bottom wrap=true|false [holds elements] — Places children side by side, splitting the width equally.
- overlap at=top-left|top|top-right|left|center|right|bottom-left|bottom|bottom-right inset=number [holds elements] — Puts its later children on top of its first one, at a corner, an edge or the middle: a Sale badge on a picture, a count on an icon, a label over a card.
- grid min=number gap=number [holds elements] — Places children in responsive equal columns.
- card gap=number pad=number variant=raised|hero|tinted|outline on=action [holds elements] — A surface that groups related content, stacked vertically.
- list [holds elements] — A bordered list of rows, separated by lines.
- list-item "text" detail=text trailing=text icon=text on=action [holds elements; only in list] — One row of a list: a title, an optional detail line and value on the right, and anything inside it (a badge, a check, a row) under the title.
- accordion "text" bind=path! gap=number on=action [holds elements] — A heading that shows or hides the content under it when pressed.
- divider — A thin horizontal line separating sections.
- spacer size=number — Empty vertical space.
- each items=expr! as=name index=name key=expr [holds elements] — Repeats what's inside it once per item of a list, in place, with no look of its own; inside, the current item is called item (or the name given by as=).
- timeline gap=number [holds step] — Steps in order with a line joining them: itineraries, how-to guides, order tracking, project history.
- step "text" status=done|current|upcoming detail=text time=text icon=text [only in timeline] — One step of a timeline: a title, an optional detail line and time, and whether it's done, current or upcoming.
- carousel bind=path! min=number gap=number on=action [holds elements] — Cards side by side, a page at a time, with arrows and dots: a few options to browse, like products, hotels or recipes.
- text "text" style=display|title|lead|body|caption weight=regular|semibold|bold tone=default|muted|accent|good|warn|bad align=left|center|right strike=true|false icon=text rich=true|false — A paragraph or heading that wraps to the available width.
- image "text" src=text! ratio=16:9|4:3|3:2|1:1|2:3|3:4 fit=cover|contain — A picture, such as a product photo.
- code "text" — A short piece of code or a command, in a monospaced font on a tinted block.
- badge "text" tone=accent|good|warn|bad|muted — A small rounded label for a status or a short tag, like Shipped or New.
- avatar "text" detail=text size=sm|md|lg tone=accent|good|warn|bad|muted name=true|false — A person's initials in a circle, with their name and an optional detail line beside it.
- callout "text" detail=text tone=info|accent|good|warn|bad icon=true|false — The one thing to notice in an answer: a key finding, a warning or a next step.
- icon name=text! tone=default|muted|accent|good|warn|bad size=sm|md|lg label=text — A small symbol on its own, like a status mark or a weather sign.
- stat label=text! value=text! tone=default|muted|accent|good|warn|bad align=left|center change=text trend=up|down|flat good=up|down spark=expr — A labelled number, for key figures like totals or counts.
- rating value=number max=number bind=path label=text on=action — A star rating.
- chart ["text"] kind=bar|hbar|line|area|stacked|pie|donut|funnel|waterfall|bullet|heatmap|scatter|treemap|sankey items=expr! x=name! y=name,name! labels=text label=name size=name group=name from=name to=name rows=name target=name good=up|down total=text format=number|money|percent [holds chart-line, chart-rule, chart-note] — A chart of a list of records: bar or line for one or more numbers per item, such as revenue and last year's revenue by month, stacked for each item's total split into parts, such as revenue by product per month, area for an amount over time, hbar for a ranking with long names, such as top customers, pie or donut for how one number splits across items, such as sales by region, funnel for how many get through each stage, such as leads to won deals, waterfall for how changes add up to a total, such as revenue minus costs, bullet for each KPI against its target, such as quarterly goals, heatmap for one number across two dimensions, such as retention by cohort, scatter for how two numbers relate, such as price against rating, treemap for how a total splits across many parts, such as costs by line item, or sankey for how amounts flow from sources to where they end up, such as budget to departments to projects.
- chart-line ["text"] y=name! [only in chart] — Under a bar or line chart: another field of the same items drawn as a line on top, on the same scale, such as a forecast or last year over this year's bars.
- chart-rule ["text"] y=expr! [only in chart] — Under a bar or line chart: a level drawn across the plot, such as a target, a budget or an average.
- chart-note "text" at=text! [only in chart] — Under a bar or line chart: a marker at one item with a few words, such as a launch or an outage.
- meter ["text"] value=expr! kind=ring|gauge min=number max=number bands=text good=up|down format=number|money|percent — One number against its range, as a ring or a gauge: progress toward a goal, a score, utilization or a budget used.
- table ["text"] items=expr! key=name bind=path sort=path direction=path on=action [holds table-column] — Rows of a list of records, one table-column per field, for things like inventory, orders or expenses.
- table-column ["text"] field=name! format=text|number|money align=left|right [only in table] — One column of a table: its header and the field it shows.
- compare ["text"] items=expr! name=name pick=text [holds compare-row] — Two to four options side by side with one row per attribute, marking the best value in each row: plans, products or choices to weigh up.
- compare-row ["text"] field=name! format=text|number|money best=low|high [only in compare] — One row of a comparison: an attribute and the field each option's value comes from.
- details ["text"] subtitle=text [holds detail] — One record as label and value rows, for a single customer, order, policy or ticket: its fields one per line, with an optional status badge.
- detail "text" value=text! tone=default|muted|accent|good|warn|bad [only in details] — One row of details: a field's name and its value.
- button "text" on=action variant=primary|secondary|danger disabled=cond — A pressable button that runs actions.
- link "text" on=action! — Pressable text for a secondary action, lighter than a button.
- check "text" bind=path! — A checkbox that toggles a true/false state value.
- switch "text" bind=path! on=action — An on/off switch with a label, for settings that take effect right away.
- radio "text" bind=path! value=text! on=action — One choice among several.
- select ["text"] items=expr! value=name label=name bind=path! multiple=true|false placeholder=text unavailable=name disabled=cond on=action — A field that opens a list to pick one option from, for choices like a department, status or filter; compact where radio buttons would take too much room.
- date-picker ["text"] bind=path! min=text max=text range=true|false placeholder=text required=true|false disabled=cond on=action — A field that opens a month calendar to pick one date, kept in bind= as YYYY-MM-DD text ("" for none), or with range=true a start and an end.
- stepper ["text"] bind=path! min=number max=number step=number on=action — A number with minus and plus buttons, for small counts like a quantity.
- slider ["text"] bind=path! min=number max=number step=number on=action — Picks a number in a range by dragging a handle, or with the arrow keys.
- input ["text"] bind=path! placeholder=text kind=text|email|number|password|search lines=number required=cond error=text on=action disabled=cond max=number — A text field people type into, such as a name, an email, a search, a new item, or with lines= a note or a message.
- segmented bind=path! label=text options=text! on=action — Two to six short options in one track, one chosen, like Week, Month and Year, or Chart and Table.
- screen name=name! title=text gap=number [holds elements; only in top level] — One screen of a multi-screen interface.
- tabs bind=path! on=action [holds tab] — A row of tabs that switches between sections.
- tab "text" value=text gap=number [holds elements; only in tabs] — One tab inside tabs: its label, and the content shown while it is selected.
- breadcrumb "text" bind=path on=action — Where the user is, as a path like Shop / Shoes / Trail runners.
- pagination bind=path! pages=number! on=action — Previous, next and page-number buttons for moving through pages of results.
- progress value=number! max=number label=text tone=default|muted|accent|good|warn|bad — A horizontal bar showing how far a value is toward a maximum.
- skeleton shape=text|block|circle lines=number height=number — A grey placeholder shown while content loads: lines of text, a block or a circle.
- empty "text" detail=text action=text on=action — A friendly message for when there is nothing to show, with an optional button to move on.
- schedule ["text"] items=expr! view=day|week|timeline date=name start=name end=name label=name rows=name tone=name key=name bind=path on=action — Blocks laid out on time from a list: a week of shifts, the day's room bookings, an itinerary by hour (times as HH:MM), or a project plan across dates (view=timeline, dates as YYYY-MM-DD).
- board ["text"] items=expr! stage=name! stages=text title=name detail=name value=name format=text|number|money sum=name tone=name key=name bind=path move=true|false on=action — Columns of cards by stage, from a list: a sales pipeline, a task board, hiring stages, support tickets.
- tree ["text"] items=expr! id=name parent=name label=name detail=name value=name format=text|number|money kind=list|org open=number bind=path on=action — A hierarchy from a flat list where each record names its parent: an org chart, folders, a budget by department.
- flow ["text"] items=expr! id=name label=name next=name kind=name edges=expr direction=down|right bind=path on=action — Boxes joined by arrows, laid out automatically from a list of steps and where each leads: an approval process, a customer journey, a decision tree.
- tile ["text"] icon=text shape=card|square size=sm|md|lg detail=text tone=default|muted|accent|good|warn|bad variant=raised|outline selected=cond disabled=cond on=action label=text — A small fixed-size face with a value, an optional icon and a detail line, for playing cards, seats, dice, keys or lottery numbers.
- scroll ["text"] size=sm|md|lg gap=number pad=number [holds elements] — A region that shows its children up to a fixed height and scrolls the rest, for a long list inside a card: messages, activity, results.
- dialog ["text"] bind=path! size=sm|md|lg on=action [holds elements] — A panel over the screen for something that needs an answer or attention first: confirm a delete, a short form, one item's details.
- menu ["text"] label=text variant=secondary|primary disabled=cond align=start|end [holds menu-item] — A button that opens a short list of actions, for what can be done to one item (edit, duplicate, delete) without a row of buttons.
- menu-item "text" on=action! icon=text tone=default|bad disabled=cond [only in menu] — One action in a menu's list.
- map kind=markers|choropleth|bubble items=expr region=name value=name lat=name lon=name label=name legend=true|false legendTitle=text highlight=text bind=path fit=world|pins cluster=true|false interactive=true|false controls=true|false route=true|false [holds pin] — Canvas map with pin markers, data-driven country choropleths and proportional bubble maps.
- pin "text" lat=number! lon=number! detail=text tone=ink|accent|good|warn|bad|muted size=number [only in map] — A labelled point on a Canvas map.
PICK BY WHAT YOU'RE SHOWING (use these instead of rebuilding them from stats, progress bars, text or a plain bar chart)
- a headline number with its change and recent trend: stat change="+12%" spark=weekly
- a target or forecast on bars: chart kind=bar holding chart-rule y=50000
- a ranking or a top 10 with long names: chart kind=hbar
- parts of each total, such as revenue by product per month: chart kind=stacked y=hardware,software
- an amount over time, such as visitors a week: chart kind=area
- two or more series over the same time, such as daily users on web and on mobile: chart kind=line y=web,mobile
- how one whole splits up, such as sales by region: chart kind=donut
- stages people drop out of, such as leads to won deals: chart kind=funnel
- changes adding up to a total, such as revenue minus costs: chart kind=waterfall total="Net income"
- several KPIs, quotas or budgets against their targets (good=down when under target is good): chart kind=bullet target=goal
- one number across two dimensions, such as retention by cohort or orders by weekday and hour: chart kind=heatmap rows=day x=hour y=orders
- how two numbers relate, such as risk against return: chart kind=scatter x=risk y=return label=name
- parts of a total when there are too many for a pie: chart kind=treemap
- how amounts flow from sources to where they end up: chart kind=sankey from=source to=target
- one score against its range, such as a health score: meter kind=gauge bands="60,85"
- many rows with several columns: table items=rows holding table-column field=name
- 2 to 4 options side by side, such as plans or products: compare holding compare-row best=low
- one record's fields, such as an order, a customer or a flight: details holding detail
- shifts, bookings or a project plan (Gantt) on a calendar: schedule view=week|day|timeline
- a pipeline or task board by stage: board stage=status move=true
- a nested breakdown, or an org chart (kind=org): tree items=rows parent=parent
- a process, journey or decision tree: flow items=steps next=next kind=kind
- places on a map: map holding pin
- a badge over a card: overlap
- steps in order with their status, such as a delivery: timeline holding step status=done|current|upcoming
- cards to page through, such as photos: carousel bind=page
- a long list inside a card, such as messages or activity: scroll size=md
- the one takeaway or warning to notice first: callout tone=warn
- playing cards, seats, dice or keys: tile "A" icon=spade
- one choice from a long list: select items=teams bind=team
- filtering by several values at once, such as regions or statuses: select items=regions bind=shown multiple=true
- choosing a date, or a period such as the last 30 days (range=true): date-picker bind=due
- a form that says what's missing or wrong: input required=true kind=email
- switching a view between 2 to 6 options: segmented bind=view options="Week,Month,Year"
- several actions on one item without a row of buttons: menu holding menu-item
- how much of one total is done, such as pages read of a manual: progress value=read max=pages
- nothing to show yet, or no results: empty "No results"
- checking before something that can't be undone: dialog bind=confirm size=sm
RULES
- Use only these elements and props. No colors, sizes or fonts: choose roles with tone=, variant= and style=; for "red" or "big", use the closest role.
- Cards group related content; leave page titles outside decorative cards. One task per screen, short labels, realistic values.
- Dashboards: grid min=170 gap=12 for peer KPI/chart cards (equal heights per row); card pad=18 gap=10 for tighter panels; avoid nested framed tables and giant outer wrapper cards.
- Screens may be narrow. Side-by-side figures: grid min=140, not row. Short stat values: compact() for large numbers.
- For several screens, write the starting screen first.
- Label every control and title every chart (or put a text just above it): screen readers read them.
EXAMPLE (todo)
state task="" todos=[{text: "Pack water", done: true}, {text: "Charge headlamp", done: false}, {text: "Snacks", done: false}]
card
text "Packing list" style=title
text "{count(todos, 'done')} of {len(todos)} packed" tone=muted
each items=todos as=t
row
check "{t.text}" bind=t.done
button "Remove" variant=secondary on=remove:t
row
input bind=task placeholder="Add an item" on="add:todos:{text: task, done: false};set:task:''"
button "Add" on="add:todos:{text: task, done: false};set:task:''" disabled={trim(task) == ''}
IMAGES
- Only use image with a URL the user or app gave you. Never make up image URLs; leave images out instead.