eUI round 1 contract
Round 4 (VAPP-103): this is the round’s historical record. Names it uses that round 3 folded (ToggleGroup, TabBar, ButtonGroup, DropdownMenu, ContextMenu, Sheet, HoverCard, Pill, EntityChip, Band, RowList and the ListRow family) are REMOVED from the catalog;
round-3-contract.md§4 maps each to what replaces it.
Round 1 makes one A2UI surface render faithfully, responsively and
accessibly on web, desktop, iOS and Android. This file is the CONTRACT half:
what changed in @exponential_app/eui (catalog, macros, style, tokens,
themes, the TS reference, fixtures, generated constants) and what each
renderer (React, the Rust core, gpui, SwiftUI, Compose) must implement to
match. Everything below is locked by a fixture or a test in this package;
the section numbers are the ones the catalog $comments cite.
Reading order for a renderer lane: §0 (what moved), then the section for the area you own, then §9 (your checklist), §10 (breaking changes).
0. What moved, at a glance
Section titled “0. What moved, at a glance”| area | change | source of truth | locked by |
|---|---|---|---|
| bound macro inputs (audit bug A) | the expander EMITS function calls for bound inputs; renderers evaluate at bind time | catalog/macros.json $comment, src/expr.ts, src/macros.ts, src/dynamic.ts |
fixtures/catalog-macros.json (bound:* cases), fixtures/bind-time.json |
| two-way binding for macros | $set → functionCall: {call: "set"} on the part’s action |
catalog/macros.json, src/macros.ts, src/dynamic.ts runAction |
bind-time.json presses |
| core functions | percent add sub eq lt clamp cond fallback concat coalesce text map len fill + action set; the BIND table adds the basic and or not required (bindFunctionNames) |
core.catalog.json functions.core, src/expr.ts CORE_FUNCTIONS, src/dynamic.ts BIND_FUNCTIONS |
expr.test.ts, bind-time.json, round1-fixes.test.ts |
| style conditions | max-width, min/max-height, orientation, hover, prefers-reduced-motion, $breakpoint.*; states :hover :focus-visible :pressed |
catalog/style.json conditions, src/style.ts resolveConditions |
fixtures/style-conditions.json |
| style keys | 23 new keys (transition, transform, gradient, per-side widths, per-corner radii, text styling, overflowX/Y, visibility, pointerEvents, userSelect, cursor, …) | catalog/style.json |
style.test.ts, schema |
| responsive props | {base, sm?, md?, lg?, xl?} on responsive props |
core.catalog.json, src/macros.ts |
catalog-macros.json (responsive:*), kitchen sink |
| catalog | 21 new components, Table macro → native, new props everywhere | core.catalog.json, macros.json, recipes.json |
catalog-components.json, catalog-macros.json |
| built-in strings | $string.<id>, host-overridable table |
catalog/strings.json, src/strings.ts |
bind-time.json, round1.test.ts |
| locale | week start, likely region (CLDR), language aliases, RTL, mirrored glyphs | catalog/locale.json, src/locale.ts |
round1.test.ts, round1-fixes.test.ts |
| CodeBlock tokenizer | one scanner, 15 languages | catalog/code.json, src/code.ts |
fixtures/code-tokens.json |
| chart numbers | extents, nice ticks, colour order, donut hole | src/chart.ts |
round1.test.ts |
| accessibility | machine-readable roles; role + notes + keyboard per component; macro parts carry role/states/name ($a11y → accessibility); rules, host commands |
catalog/a11y.json, catalog/macros.json, src/a11y.ts, src/macros.ts |
round1.test.ts, round1-fixes.test.ts, catalog-macros.json |
| bind pass | per-ROW Table slot cells, DATA props verbatim, bound accessible names | src/dynamic.ts |
bind-time.json (extra, rowSlots) |
| theming | breakpoint/ease/density tokens, chart6–8, shadow xl, contrast overlays, system mode, recipe motion |
catalog/tokens.json, themes/*, src/theme.ts |
theme-*.json, control-geometry.json |
| prompt | teaches responsive rules, states, visible, keys; common props described once |
src/prompt.ts |
prompt-budget.json (6451 / 6500 tokens) |
1. Bound macro inputs and two-way binding
Section titled “1. Bound macro inputs and two-way binding”The bug
Section titled “The bug”Macros expand in the reducer before any data exists. expr.ts used to
compute $percent, string interpolation, $cond, $eq, $add on the
{path} OBJECT: Progress value: {path} drew width: "0%", Pagination
page: {path} printed [object Object] / 5, Collapsible open: {path} was
always open (a binding object is truthy) and never wrote back, a bound Pill
label passed only by luck.
The design (chosen: emit, never re-expand)
Section titled “The design (chosen: emit, never re-expand)”Re-expanding on every data change would make expansion a function of data
and force every renderer to re-run the macro table per frame. Instead, when
ANY input of a template expression is DYNAMIC (an A2UI data binding
{path} or a function call {call, args}), the expander emits an A2UI
function call over the CORE functions; the renderer evaluates it at bind
time exactly like an authored call. Expansion stays a pure function of the
surface, so the TS reference and the Rust core stay line-by-line mirrors.
| template form | literal inputs | a dynamic input emits |
|---|---|---|
"{props.x}" (whole string) as a MEMBER of props/style/context/$recipe or an array item |
the value | the dynamic value itself (two-way binding survives) |
"{props.x|d}" as a member |
value, else d |
the dynamic value itself (the fallback is dropped) |
"{props.x|d}" as an OPERAND (inside any $-object, $cond/$map results included) |
value, else d |
fallback{value, default: d} |
"a {props.x} b" |
the joined string | concat{values: ["a ", x, " b"]} (empty pieces dropped) |
"{!props.x}" |
boolean | not{value} |
"{len(props.x)}" |
length | len{value} |
"{range(props.x)}" (for $each) |
[0..n-1] |
nothing (no items) |
$map {from, cases, default} |
the case | map{value, cases, default} |
$cond [c, then, else] |
then/else | cond{if, then, else} |
$eq $lt $add $sub $percent |
value | eq{a,b} lt{a,b} add{a,b} sub{a,b} percent{value,max} |
$text |
String(v) | text{value} |
$coalesce [a, b, …] |
first truthy | literals that are falsy are skipped; the first truthy literal BEFORE any dynamic wins; else coalesce{values: [dyn…, first truthy literal]} |
$clamp [v, min, max] |
the clamped number | clamp{value, min, max} |
$fill [template, {name: v}] |
ALWAYS fill{template, params} (the template is a $string.<id> only the surface’s table resolves) |
fill{template, params} |
$not |
boolean | not{value} |
$if / $any on a part |
decides at expansion | the part IS emitted with visible = the dynamic condition (and{values} for several $ifs, or{values} for several $anys; one alone is itself) |
$each over a dynamic array |
one part per item | nothing (use List with a template) |
Template KEYS interpolate too ("@media (max-width: $breakpoint.{props.collapseBelow|md})").
Truthiness everywhere (expander and renderer) = truthy: undefined,
null, false and "" are false; 0 is TRUE.
Core functions (core.catalog.json functions.core)
Section titled “Core functions (core.catalog.json functions.core)”percent{value, max} → "N%" clamped 0..100, two decimals, 0% when max
≤ 0 · add{a,b} / sub{a,b} (non-numbers count 0) · eq{a,b} strict,
EXCEPT a number equals a string spelling it (5 = "5": form and URL state
arrive as strings; 0 ≠ "", 1 ≠ true) · lt{a,b} numeric ·
clamp{value, min?, max?} (a number; a missing bound does not clamp; min
wins when max < min) · cond{if, then, else} · fallback{value, default}
(value unless undefined/null; 0, "" and false are values) ·
concat{values} (undefined/null as “”) · coalesce{values} (first truthy)
· text{value} (String, missing stays missing) · map{value, cases, default} (cases[String(value)]) · len{value} (array or string length,
else 0) · fill{template, params} ({name} placeholders filled, unknown
ones stay; the template arrives already resolved through the string table) ·
set{path, value} (ACTION only).
The bind table is MORE than the core names. The expander also emits the
basic catalog’s not ({!x}, $not), and (several bound $ifs) and
or (several bound $anys), with the same truthiness; required completes
the reference table. A renderer implements ALL of bindFunctionNames
(generated; = src/dynamic.ts BIND_FUNCTIONS): a table of only
coreFunctionNames resolves Collapsible’s set value not{…} to nothing.
Arguments resolve at ANY depth (arrays and objects of bindings/calls).
Two-way binding: $set and the combined action
Section titled “Two-way binding: $set and the combined action”A template part may say "$set": {"press": {"prop": "open", "value": "{!props.open}"}}.
When the author’s props.open is a DataBinding {path: P}, the part’s
press action gets functionCall: {call: "set", args: {path: P, value: <evaluated value>}}
next to any routed author event (key order event, then functionCall). A
literal prop adds nothing — the host keeps owning that state through the
routed event, as before. An Action carries ONE function: when the author’s
routed handler is itself a functionCall (e.g. openUrl), it wins, NO set is
added and the reducer reports on.<event>: a function action replaces the two-way set of props.<prop>; … once per macro (never lost silently; locked
by bind-time.json extra TabBar/set:author-function). Used by: Collapsible (open), Pagination (page),
ButtonGroup (selected), TabBar (value), Rating (value), AlertDialog
cancel/confirm (open → false).
Action semantics (src/dynamic.ts runAction): resolve the function’s
args AND the event context against the data model AS IT IS at the press,
then apply set (write value at path, relative paths against the
node’s data scope like a binding; missing objects are created), then
dispatch the event. So a Collapsible’s routed event carries the NEW open
(!old), and the write does not double-negate it.
Natives with bindable state (Dialog/Drawer/Popover/Toast open,
Tabs/ToggleGroup/Select/Radio/Input/… value, Checkbox/Switch checked,
Toggle pressed, Carousel page, Table sort/selected, ChipInput
values, DateRangePicker start/end, NumberField/TimePicker value):
when the prop is a binding, the renderer writes the user’s change to that
path itself AND fires change (this is the two-way binding the macros now
reach through set).
The bind pass (reference: src/dynamic.ts)
Section titled “The bind pass (reference: src/dynamic.ts)”After expansion, per data model: bindTree(node, data, {scope?, strings?, extensions?}):
- Props resolve along the component’s SCHEMA (
resolveNodeProps): a binding or call anywhere resolves; arrays and SHAPED objects recurse; a DATA position — a shape-lessobjector an array of them, today only Tablerows(generateddataProps) — is the author’s data: a LITERAL there is copied VERBATIM (a row{path: "/etc/hosts"}, acallcolumn or a"$string.cancel"cell stay data), a dynamic value AT the position resolves and its result is never descended. Values a binding returns are never descended or$string-resolved anywhere. Everything else (resolveDynamic) resolves at any depth: bindings by JSON pointer (absolute/a/b, or relative to the scope), calls by the BIND table,$string.<id>through the surface’s string table. - STYLE values, recipe props and
accessibility(A2UI’slabel/descriptionare DynamicStrings; the$a11ystates too) resolve the same way; an empty result is dropped. - A node whose
visibleresolves falsy is dropped (with its subtree, no layout);visibleis removed. Actions stay untouched (press time). - A
templateand the slots of a ROW-SCOPED component (catalogslotScope: "row", generatedrowSlotComponents: Table) stay UNBOUND. The painter binds each slot cell once per row withbindRowSlot(slot, rowsProp, rows, index, data):rowsProp= the UNBOUNDrowsvalue — a binding gives the scope{base: "<its pointer>/<index>"}(relative paths read the row in the model, a relativesetwrites into it); a literal or a call gives{item: rows[index]}(relative paths read inside the row, absolute ones the surface data, a relativesetwrites nothing).indexis the row’s index inrowsas given, BEFORE any local sort. A cell whosevisibleresolves falsy for a row renders empty for that row.
After expansion a STYLE value may be dynamic (Progress fill width: percent{…}); resolve it, then apply it like an authored value. Authors
cannot write dynamic style values (validateStyle refuses).
fixtures/bind-time.json — cases: 26 (every bindable macro prop bound to
/<prop>), each × THREE data models (a sample, its falsy twin, and {} =
the path MISSING, so every operand’s fallback{value, default} takes its
default; 0 is TRUTHY, so the number twin exercises arithmetic, not
truthiness) → the bound tree and every set press outcome. extra: 5
hand-written cases (bound-rows and literal-rows Table slot cells with
rowSlots = every row’s bound cells, DATA rows verbatim, a bound accessible
name, an author function meeting $set with the reducer’s issues, a
numeric prop bound to string data). The audit cases are pinned in
round1.test.ts (“the audit’s bugs”), the review fixes in
round1-fixes.test.ts.
2. Responsive authoring and the style whitelist
Section titled “2. Responsive authoring and the style whitelist”Conditions (catalog/style.json conditions, src/style.ts)
Section titled “Conditions (catalog/style.json conditions, src/style.ts)”One level deep, ONE regex grammar (STYLE_MEDIA_PATTERN):
@media (min-width: N)/(max-width: N)/(min-height: N)/(max-height: N)—N=<number>pxor$breakpoint.<name>; sizes are the SURFACE box (not the window);min-*=size >= N,max-*=size < N(strict, somin-width: mdandmax-width: mdnever overlap — React: emit@container xui (width < Npx), notmax-width).@media (orientation: portrait|landscape)— portrait = height ≥ width; landscape when the height is unknown.@media (hover: hover|none)— a hover-capable pointer.@media (prefers-reduced-motion: reduce|no-preference)— the platform setting.@media (form-factor: phone|wide)(VAPP-218) — the HOST’s form factor, an optionalformFactorsurface input (an editor preview’s phone frame, a phone); unset =phonewhen the surface width < the theme’s$breakpoint.md(768 by default), elsewide.- NOT supported:
prefers-color-scheme(mode belongs to the theme), ranges,em,screen, nesting. - States
:hover,:focus-visible,:pressedagainst the node’s own interaction state (:focus-visible= keyboard focus only).
Flattening (resolveConditions, the reference): base keys, then every
MATCHING @media block in SOURCE order (later wins; authors write
narrow-first), then :hover, :focus-visible, :pressed in that order
(pressed wins). A $breakpoint token resolves through the theme
(theme.tokens.breakpoint) BEFORE matching; an unknown token never matches
(and validateStyle reports it). resolveStyleValues also rewrites the
token inside media KEYS (resolveConditionKey). Locked by
fixtures/style-conditions.json (7 cases × contexts).
Breakpoint tokens
Section titled “Breakpoint tokens”tokens.json breakpoint: [sm, md, lg, xl], px, per theme (built-ins:
640 / 768 / 1024 / 1280, Tailwind’s). activeBreakpoint(width, bp) = the
LAST name whose px ≤ width, null below sm (= base).
Responsive props
Section titled “Responsive props”A prop with responsive: true also takes {base, sm?, md?, lg?, xl?};
the value at a breakpoint = the nearest key at or below it (base under
the first) — responsiveAt. Validation checks each value and refuses
other keys. Today: Stack direction gap align justify wrap, Grid
columns gap, Drawer side.
- On a MACRO the expander evaluates each part’s style with the
baseprops, then once per breakpoint the value names (ascending, each inheriting the last), and every key that changed lands in an"@media (min-width: $breakpoint.<bp>)"block of that part. Props, conditions and recipe props seebase(a responsive value cannot add or remove parts). A key cannot be UNSET at a breakpoint. - On a NATIVE the renderer picks
responsiveAt(value, activeBreakpoint(surfaceWidth, theme.tokens.breakpoint))at layout time (Drawer: a bottom sheet on phones, a side panel from md), and queries the recipe with that RESOLVED value (when: {side: "right"}never sees the object). - An author
styleon a macro still merges over the root’s, condition blocks merged key by key, so a Box style override remains a second way.
Whitelist additions (catalog/style.json; schema + STYLE_KEY_NAMES ×4)
Section titled “Whitelist additions (catalog/style.json; schema + STYLE_KEY_NAMES ×4)”| key | values | notes |
|---|---|---|
overflowX, overflowY |
visible hidden clip scroll auto | layout |
insetBlockStart, insetBlockEnd |
length | layout (logical top/bottom) |
borderTopWidth … borderLeftWidth |
number / $border.* |
LAYOUT EFFECT like borderWidth (STYLE_LAYOUT_EFFECT_KEYS); taffy border rect per side |
borderStyle |
solid dashed dotted | default solid; a width without a style is solid |
borderTopLeftRadius … borderBottomLeftRadius |
length | per corner, over borderRadius |
backgroundGradient |
{angle, stops: [{color, offset 0..1}]} (≥ 2 stops) |
linear, CSS angle (0 = up, 90 = right), painted OVER backgroundColor (the colour shows only through transparent stops) |
fontWeight |
300 400 500 600 700 800 | was 400–700 |
letterSpacing |
length | px |
textAlign |
left right center start end justify | start/end flip in RTL |
textDecoration |
none underline line-through | |
textTransform |
none uppercase lowercase capitalize | |
fontStyle |
normal italic | |
transition |
$motion.<name> ONLY |
the duration opacity/colour/transform/size changes animate with; nothing animates without it; reduced motion = 0 ms |
transitionEasing |
$ease.<name> ONLY |
cubic-bezier [x1, y1, x2, y2] |
transform |
translate(Xpx, Ypx), scale(N), rotate(Ndeg), space-separated |
PAINT-ONLY: never changes layout, hit-testing follows paint where the platform does |
visibility |
visible hidden | hidden keeps its box, paints nothing, leaves the a11y tree |
pointerEvents |
auto none | none = presses pass through |
userSelect |
auto none text | |
cursor |
auto default pointer text not-allowed grab grabbing move col-resize row-resize | a hint pointer platforms honour, touch ignores |
Still excluded (decided): zIndex (paint order = tree order, absolute
children LAST among siblings, overlays stack by LAYER), per-side border
COLOURS (gpui paints one border colour per box), calc(), named grid
lines, gridAutoFlow, justifyItems, order, multi-value shorthands.
CSS mapping lives in src/css.ts (styleToCss: transition →
all <ms>ms cubic-bezier(…), backgroundGradient → background-image: linear-gradient(…), any non-zero width without colour → transparent,
without style → solid).
3. Catalog
Section titled “3. Catalog”82 components (46 natives, 36 macros), 81 visible.
New components
Section titled “New components”| component | kind | why that kind / key behaviour |
|---|---|---|
| Form | native | owns field collection, submit gating and error focus |
| NumberField | native | steppers, clamping, locale formatting |
| ChipInput | native | token editing, keyboard |
| DateRangePicker | native | one calendar, two-step selection |
| TimePicker | native | list or typed HH:mm |
| FileUpload | native | drop zone, file picker, host upload callback |
| CodeBlock | native | tokenizer, gutter, copy |
| ContextMenu | native | right-click / long-press, shares DropdownMenu’s menu |
| Toast | native | toast LAYER, timers, live region |
| Table | native (was a macro) | typed cells, sort, selection, windowing, slot cells |
| ScrollArea | macro | Box with overflowX/Y |
| Sidebar | macro | two columns, the column hidden below collapseBelow (a max-width block) or when collapsed (bound → visible). NOTHING replaces it on narrow screens (one slot cannot render twice without duplicating ids and state): the AUTHOR gives phones another way in — a TabBar, or a Sheet behind an AppBar menu button ($string.menu) holding the same destinations; the catalog description (= the prompt) says so |
| AppBar | macro | title/subtitle/back + leading/trailing slots spliced in place ("$slot:name") |
| TabBar | macro | bottom destinations, $set value |
| Breadcrumb | macro | links + current, select event |
| Stepper | macro | status = done/current/upcoming per step (a $recipe discriminator) |
| Rating | macro | range(max) stars, $set value |
| Kbd, Label | macros | text styling |
| AlertDialog | macro over Dialog | dismissible: false, a template-built footer SLOT part (cancel/confirm), $set open → false |
| HoverCard | macro over Popover | openOn: hover |
| Sparkline | macro over Chart | kind: sparkline, no axes/grid/legend |
Changed components
Section titled “Changed components”- Table (BREAKING):
columns: [{key, label, type?, slot?, width?, align?, sortable?}],rows: [object](bindable),rowKey="id",sort {key, direction}(bindable: bound = the host ordersrows; unbound = the renderer sorts a local copy: numbers numerically, strings by the locale collator, missing last),selectable none|single|multiple(multiple = a checkbox column with a select-all header),selected [keys](bindable),caption,emptyText,striped(the painter queriesrowwithstriped: trueon ODD rows),density,stickyHeader. Celltype: text, number (locale number), date (locale date), boolean (a tick), badge (the value in a Badge look), slot (the Table slot named byslotrenders once per row with the ROW as its data scope: relative bindings resolve into the row — §1 “The bind pass”,bindRowSlot).slots: ["*"]= any slot name;slotScope: "row".rowsis DATA: literal rows are never interpreted. Windowed pastWINDOW_THRESHOLD= 50 rows. - Chart: kinds
bar stackedBar line area pie donut sparkline;min,max,xLabel,yLabel,showAxes,showGrid,showLegend,showValues; categories optional (sparkline); the width is the box’s (no fixed viewBox). Numbers every painter shares (src/chart.ts): extent (mindefaults to 0 or the lowest negative,maxto the largest value but at least 0 — for stackedBar the largest category sum of the POSITIVE values up and the lowest sum of the NEGATIVE ones down; sparkline spans its own min..max; computed in loops, never by spreading, so a 150k-point series is fine), the accessible name$string.chartSummary/sparklineSummaryfilled fromchartSummaryParams/sparklineSummaryParams,niceTicks(min, max, 5)(Heckbert, steps 1/2/5×10ⁿ, rounded to the step’s decimals) for axis ticks and grid lines, colours = the series tone, else$color.chart1..8in order (wrapping), donut hole = 0.6 × radius, the legend only with 2+ series or slices, the tooltip part on hover/focus. - Image:
fallbackicon (on error or no src; default glyphbuiltinIcons["Image.fallback"]),loading lazy|eager,focalX/Y0..1 (the point kept when cropped, 0.5 default). - Drawer:
dismissible,dragToDismiss, responsiveside. - Textarea:
autosizefromrowstomaxRows. - Markdown:
linestruncation,style. Text:live off|polite|assertive. - Popover:
openOn press|hover(hover also opens on keyboard focus). - Select = the combobox:
searchableadds a filter field and firessearch {query}(debounce 150 ms);optionsis bindable so the host refills it (async source);sourcenames a host list;emptyText. - Button:
submitsubmits the enclosing Form. - DatePicker:
firstDayOfWeek(else the locale’s, §4). - DropdownMenu + ContextMenu
menuItem.kind:item | checkbox | separator | label | submenu(checkedbindable,itemsone level deep,shortcut).
Event payloads (what the renderer merges into the author’s event context)
Section titled “Event payloads (what the renderer merges into the author’s event context)”The payload is merged OVER the author’s resolved context: on a clashing key
the component’s payload WINS (React hardening.test.tsx, Rust
Surface::fire). The payload is also still sent as its own payload field.
| component | event | context |
|---|---|---|
| value controls (Input, Textarea, Select, Radio, ToggleGroup, Tabs, Slider, NumberField, TimePicker, DatePicker, Composer) | change |
{value} (NumberField: a number) |
| Checkbox, Switch / Toggle | change |
{checked} / {pressed} |
| Dialog, Drawer, Popover, Toast (and the macros over them) | change |
{open} |
| DateRangePicker | change |
{start, end} |
| ChipInput | change / add / remove |
{values} / {value} / {value} |
| Select | search |
{query} |
| DropdownMenu, ContextMenu | select |
{value} (+ {checked} for a checkbox item, its NEW state) |
| Table | sort / select / rowPress |
{sort: {key, direction}} / {selected: [keys]} / {key, row} |
| Form | submit / invalid / change |
{values: {<field name>: value}} / {errors: [{name, message}]} / {name, value} |
| FileUpload | upload / remove |
{files: [{name, size, type}]} / {name}; the BYTES go to the host’s onUpload(files, {nodeId, name}) callback, never through an event |
| Toast | dismiss / action |
{} |
| Input, NumberField / Composer | submit |
{value} |
| macros | as routed by $on + $context |
e.g. Pagination {page}, Breadcrumb {value}, Rating {value} |
Collects every field beneath it that has a name (Input, Textarea,
NumberField, Checkbox, Radio, Switch, Select, ChipInput, DatePicker,
DateRangePicker, TimePicker, FileUpload, Slider) — nested Forms are not
collected by the outer one. Submit (a submit Button, Enter in a
single-line field) runs EVERY field’s checks regardless of validateOn;
any failure: no submit, invalid {errors} fires, EVERY failing field
shows ALL its failed messages under it (id <field id>.error, linked by
aria-describedby / the platform equivalent), summary: true also lists
them above the fields, focus moves to the first invalid field and
invalidFields is announced. busy refuses submits and shows the submit
Button loading; disabled makes every field inert.
A native in the TOAST layer (above dialogs), stacked bottom-centre
(phones) / bottom-end (wide), newest nearest the edge, max 3 visible. The
host queues by rendering one Toast per notice with open bound; the
renderer runs duration (0 = sticky; paused while hovered or focused),
writes open: false, fires dismiss + change. error toasts are
assertive, others polite; a toast never takes focus.
CodeBlock tokenizer (catalog/code.json, src/code.ts)
Section titled “CodeBlock tokenizer (catalog/code.json, src/code.ts)”One forward scan, first matching rule wins: line kinds → line comments →
block comments → strings (\ escapes; stop at a line break unless the
quote is multiline; keyStrings makes a string before : a property) →
markup tags (html) → numbers ([0-9][0-9A-Za-z_.]*) → identifiers
(keyword | property before : with keyIdents | function before ( |
property after . | type (typeNames or Capitalized) | plain) → spaces →
operator runs → one punctuation char → one plain char. Then split into
LINES and merge adjacent same-kind tokens; a line’s tokens concatenate to
exactly its text. Kinds = the codeToken enum; the recipe is
CodeBlock/token with when: {kind}. fixtures/code-tokens.json (17
cases, all 15 languages + edge cases).
Built-in glyphs (core.catalog.json builtinIcons)
Section titled “Built-in glyphs (core.catalog.json builtinIcons)”<Component>.<part>[.<variant>] → an icons.json name, e.g.
CodeBlock.copy → ui-copy, FileUpload.icon → upload,
Table.sortIcon.asc → ui-chevron-up, Toast.icon.error → ui-error.
Generated as builtinIconSlots / builtinIconNames (×4). Never hardcode
a glyph for an owned part.
4. Data, locale and strings
Section titled “4. Data, locale and strings”visible(every component, reserved key likestyle):true | false | {path} | {call}; falsy once resolved = not rendered, no layout, not in the a11y tree. The expander also sets it on parts whose$ifwas bound. On a macro the author’svisiblelands on the expanded root.- Template keys:
children: {componentId, path, key?}→ normalizedtemplate: {component, path, key?};key= a pointer relative to each item whose value identifies it, so reordering keeps the item’s component state (focus, scroll, local input); the index when absent. accessibilityis now copied byreduceSurfacetoo (it was only inreduceNested).- SurfaceSettings (
src/types.ts):locale(BCP 47, defaulten-US),strings(overrides),mode(light | dark | system),density,contrast(normal | high | system),theme. - Locale contract (
catalog/locale.json,src/locale.ts): formatting is the platform’s ICU (Intl / Foundation / java.text / icu4x) in the SURFACE locale forformatNumber,formatCurrency,formatDate,pluralize, NumberField, Table number/date cells, calendar month and weekday names. What must not differ is data:parseLocalefirst maps deprecated language codes (languageAliases:iw→he,ji→yi,in→id,jw→jv,mo→ro,tl→fil; Java/Android still emit them);weekStart(locale)(CLDR firstDay by region; a bare language takeslikelyRegion= CLDR likelySubtags for every rtl language and ~90 common ones, e.g.ur→PK = Sunday,ps→AF = Saturday,dv→MV = Friday; default Monday),textDirection(locale)(rtllanguages; the surface root getsdirection: rtlunless the author set one). - RTL glyphs (
locale.jsonrtlMirroredIcons,mirrorsInRtl): under rtl a painter MIRRORS exactly these directional glyphs (back, chevron left/right, arrow right, send, undo, external link — semantic and Lucide names):scaleX(-1)as the OUTERMOST transform of the glyph, after the node’s owntransform(CSStransform: scaleX(-1) <own>; SwiftUI.scaleEffect(x: -1)outside.rotationEffect; ComposegraphicsLayer { scaleX = -1 }outside), so AppBar back, Pagination/Carousel/DatePicker prev/next, Breadcrumb separators, NavRow/ListRow/Collapsible chevrons and submenu indicators point the reading direction (a rotated Collapsible chevron still points down when open). Media transport glyphs, up/down chevrons and everything else are never mirrored. Flex rows already mirror their ORDER (direction: rtl), so prev sits on the right AND points right. - Built-in strings (
catalog/strings.json, 52 ids): the copy no author writes (search placeholder, month buttons, copy, previous/next, close, confirm/cancel,{count} selected,pageOf,ratingStar“{value} of {max}”,ratingValue,stepDone/stepCurrent/stepUpcoming,chartSummary,sparklineSummary,pagination,breadcrumb, …). A sentence is ONE id with placeholders, never concatenated fragments (the fragmentofis gone); a macro fills one through$fill→fill. The host overrides per surface (stringTable(overrides));$string.<id>in a prop default, a macro template or an authored value resolves at bind time;{name}placeholders fill viaformatString.validatePropsreports an unknown id. Generated asstringIds/stringDefaults(×4).
5. Theming
Section titled “5. Theming”- Tokens added (
catalog/tokens.json, append-only):color.chart6..8;control.appBar,tabBar,toast;shadow.xl;breakpoint.{sm,md,lg,xl}(px);ease.{standard,decelerate,accelerate}([x1, y1, x2, y2]);density.{compact,comfortable}(multipliers 0.5..2). - Elevation = the shadow ladder
none < sm < md < lg < xl(flat, card, popover, dialog, toast). Inside a layer paint order is tree order; layers (base < overlay < toast) stack — there is no zIndex. - Modes:
resolveMode(setting, systemPrefersDark);systemfollows the platform live. - High contrast: a theme may carry
contrast: {light?, dark?}partial colour/shadow overlays;applyContrast(theme)merges them over the modes when the platform asks (orcontrast: high). Neutral ships overlays (foreground, mutedForeground, border, input, ring, card/popover foregrounds); the others inherit them. - Density:
applyDensity(theme, density)scalescontrolandspacingby$density.<name>and rounds to whole px, once per surface; radii, type and borders do not scale. Every resolver and fixture stays density-agnostic. - Recipes: keys add
borderTop/Right/Bottom/LeftWidth,borderStyle,letterSpacing,textDecoration,textTransform,fontStyle,transition($motion.*),transitionEasing($ease.*),transform; states addinvalid(a field whose checks failed) anddragover. Painter-suppliedwhendiscriminators:CodeBlock/token.kind,Table/headerCell|cell.align,Table/row.striped. Template-built slot parts (AlertDialogfooter) count as parts. - Built-ins: neutral has values for every new token and recipes for
every new component (motion on Button, Toggle, Tabs, Switch, rows, Pill;
the Collapsible chevron rotates 90° when open; FileUpload’s dashed
drop zone; Stepper statuses; Rating stars; the CodeBlock palette on
chart6–8 + semantic hues); playful overrides several (overshoot easing,
pill toasts, primary chips); exponential takes its easings from
packages/design-tokensmotion.ease, addsshadow.xl,control.appBar/tabBar/toast(scripts/generate-themes.ts) and glass recipes for the new fields, menus, toast, table, code block, drop zone, sidebar and tab bar (themes/exponential.recipes.json). The theme builder edits breakpoints and density;diffThemekeepscontrast. - Geometry:
control-geometry.jsonnow also locks NumberField (field), ChipInput (field,minHeight), TimePicker and DateRangePicker (trigger).
6. Accessibility (catalog/a11y.json)
Section titled “6. Accessibility (catalog/a11y.json)”roles = the MACHINE-READABLE vocabulary (ARIA names plus text = static
text and hidden = out of the accessibility tree; generated a11yRoles).
Every component has a role (one of roles: its main accessible object),
notes (prose: composite parts, states, which strings name it) and keys
(82 entries; round1.test.ts gates it).
Macro parts carry their semantics on the tree. A template part’s
$a11y (catalog/macros.json) is evaluated like props (bound inputs emit
calls) into the expanded node’s accessibility: {role, label, description, level, expanded, current (page|step|false), selected, pressed, checked, valueNow, valueMin, valueMax, hidden, autoFocus}; the author’s
accessibility on a macro wins over its root’s. A painter maps them to the
platform (ARIA role/aria-*, SwiftUI traits/accessibilityValue, Compose
semantics { role; stateDescription; heading(); selected }, AccessKit) and
autoFocus = focus lands there when the enclosing dialog opens. Today:
Card/Group (group + title), Separator, ScrollArea (region), Band/Heading
(heading + level), RowList/Stepper (list; steps = listitem, current=step,
labelled stepDone|Current|Upcoming), Alert (alert|status), EmptyState
(status), Progress (progressbar + values), Meter, Collapsible trigger
(button + expanded), Pagination (navigation pagination, status label
pageOf), ButtonGroup (pressed), Breadcrumb (navigation breadcrumb,
current=page, separators hidden), TabBar (navigation; items button +
current=page), Rating (stars = buttons labelled ratingStar, the chosen one
pressed; read-only = img labelled ratingValue), AlertDialog (alertdialog,
cancel autoFocus), Sidebar (navigation + main), AppBar (banner, title
heading 1), NavRow (link + current), Pill (button + pressed when
pressable). Natives own theirs (a11y.json). Highlights: Tabs / Radio / ToggleGroup = roving tab stop + arrows
(wrap) + Home/End; Select = combobox + listbox, type-ahead, searchable keeps
focus in the field with aria-activedescendant; DropdownMenu / ContextMenu =
menu semantics with ArrowRight/ArrowLeft for submenus, Shift+F10 opens a
ContextMenu; Accordion headers; DatePicker grid = arrows ±1 day/week,
PageUp/PageDown month (Shift = year), Home/End by the LOCALE week start;
Tooltip shows on hover AND keyboard focus; Dialog focus trap + return;
AlertDialog focuses cancel; Toast never steals focus.
Rules (all components): keyboard reachability in tree order, focus ring
only for keyboard focus, labels/descriptions/errors linked (error id
<node id>.error), hidden nodes leave the a11y tree, icon-only controls
take label, accessibility.label/description override, reduced motion
zeroes transitions and stops shimmer.
Host → surface commands (SurfaceCommand): focus {id}, announce {text, live}, scrollIntoView {id}. Live regions: Text.live, Toast, Form’s
invalidFields announcement, CodeBlock’s copied.
7. Prompt (src/prompt.ts)
Section titled “7. Prompt (src/prompt.ts)”New rules teach: visible, template key, responsive props (marked ^)
with {base, sm?, md?, lg?, xl?}, the condition grammar with
$breakpoint.*, the three state blocks, display: "none", Form and
Sidebar, leaving built-in copy unset. Compaction to stay under budget:
props that mean the same everywhere (name, label, value, style,
checks, …) are described ONCE (COMMON_PROPS), enum-first and false
defaults are implied, only shapes the listed components use are printed.
Full prompt 6451 tokens (budget 6500), lite 5205, terse 4752 for 81
components (was 5438 for 60).
8. Generated constants (Swift / Kotlin / Rust, src/catalog.generated.ts)
Section titled “8. Generated constants (Swift / Kotlin / Rust, src/catalog.generated.ts)”New rows: styleLayoutEffectKeys, coreFunctionNames,
bindFunctionNames (the whole bind table a renderer implements),
stringIds, stringDefaults, codeLanguageNames, builtinIconSlots,
builtinIconNames, weekStartRegions + weekStartDays,
likelyRegionLanguages + likelyRegions, languageAliases +
languageAliasTargets, rtlLanguages, rtlMirroredIcons, a11yRoles,
rowSlotComponents, dataProps (Component.prop). New scalars:
styleTransformPattern, defaultLocale, weekStartDefault.
styleMediaPattern changed (the new grammar), styleStates is
[:hover, :focus-visible, :pressed], functionNames gained 15 names,
tokenGroups gained breakpoint, ease, density. The Rust file also
embeds STRINGS_JSON, LOCALE_JSON, CODE_JSON, A11Y_JSON beside the
existing catalog JSON, mirrored into
apps/desktop/crates/eui-core/src/generated/catalog.rs. Themes
generated outputs carry the new token groups and contrast.
9. Per-renderer checklists
Section titled “9. Per-renderer checklists”React (@exponential_app/eui-react, the reference renderer)
Section titled “React (@exponential_app/eui-react, the reference renderer)”- Media: map every condition (
min/max-width|height→@container xui (width >= N)/(width < N)with$breakpointresolved from the theme; orientation via the container’s aspect: emit both(orientation: …)container queries;hover/prefers-reduced-motion→ real@media). States:hover→:hover(and[data-xs~=hover]),:focus-visible→:focus-visible,:pressed→:active, [data-xs~=pressed], emitted after the media rules in that order. - Bind pass: run
bindTreesemantics in the binding layer (props along the schema with DATA props verbatim, styles, recipe props,accessibility,$string), drop falsyvisible, honour templatekeyas the React key; Table slot cells per row (bindRowSlot); mapaccessibility(role, states,autoFocus) to ARIA; mirrorrtlMirroredIconsunder rtl. - Actions:
runActionorder; implementsetagainst the surface data model. - Natives: Form, NumberField, ChipInput, DateRangePicker, TimePicker, FileUpload (host
onUpload), CodeBlock (tokenizeCodefrom this package), ContextMenu, Toast (layer + queue), Table (native, windowing ≥ 50), Chart kinds +niceTicks; Image fallback/focal point (object-position), Drawer flags, Textarea autosize, Markdown lines, Text live, Popover openOn, Select search. - New style keys via
styleToCss; motion:transition+transitionEasing; reduced motion →transition-duration: 0ms. - Theme:
resolveMode('system')viamatchMedia,applyDensity,applyContrast(prefers-contrast: more), new recipe keys/states (invalid,dragover). - Built-in strings + glyphs from the generated tables;
textDirectionfor the root.
Rust core (apps/desktop/crates/eui-core)
Section titled “Rust core (apps/desktop/crates/eui-core)”expr.rs: dynamic inputs emit calls exactly assrc/expr.ts(member vs operand rule,fallback,concat,$lt,$sub,$not,$textover a value object,range(...), key interpolation);TemplateChildgains"$slot:name"splices andslotsvalues that are template nodes (AlertDialog) —MacroTemplate.slotsbecomesIndexMap<String, SlotRef>(string | node), plus$set.macros.rs:$if/$any→visible(and/or),$set→functionCall: set(afterevent; an author FUNCTION wins and is reported),$a11y→accessibility(author label/description merged over the root’s), responsive variants → media blocks, authorvisible/accessibilityonto the root;expr.rs:$clamp,$fill(always emits),$eqnumeric-string equality. Replaycatalog-macros.json(189 cases incl.bound:*/responsive:*) andbind-time.json(cases× 3 datasets ANDextrawithissues+rowSlots).reducer.rs:visible(validate: boolean or dynamic), templatekey,accessibilityon the flat path, slot names checked (*= any).validate.rs: responsive values, objects without shape,$string.ids.style_check.rs: the new grammar (STYLE_MEDIA_PATTERN), states, unknown breakpoint tokens,transform/gradientvalue types, the new keys;resolveConditionsport +style-conditions.jsonreplay. Layout: per-side border widths into taffy’s border rect,overflowX/Y,insetBlockStart/End,visibility: hiddenkeeps the box.data.rs: the WHOLE bind table (bindFunctionNames: core +clamp/fill+ and/or/not/required;eqnumeric-string equality), bind pass dropping falsyvisible, props along the schema (dataPropsverbatim),accessibilityresolved, row-scoped slots left unbound +bind_row_slot(scopeitemfor literal rows),$stringresolution through the surface table (never inside data);reducer.rs:visibleon the BASIC path too, the$setcollision issue.theme.rs: token groupsbreakpoint/ease(tuples)/density,contrast,apply_density,apply_contrast,resolve_mode, recipe keys/states,resolveConditionKey. Replaytheme-*.json,control-geometry.json.- New modules to port (pure):
code.rs(tokenizer, replaycode-tokens.json),locale.rs(+languageAliases,rtlMirroredIcons),chart.rs(chart_extentwith loops and split positive/negative stacks,nice_ticks, the summary params), strings table. The embedded JSON constants are already generated. - FFI: expose SurfaceSettings,
SurfaceCommand, the action outcome (setwrites) so the natives do not re-implement the bind pass.
gpui painter (eui-gpui)
Section titled “gpui painter (eui-gpui)”- Paint the new keys: per-side border widths with ONE colour,
borderStyledashed/dotted (custom path), per-corner radii (corner_radii),backgroundGradient(gpui linear gradient, two stops; more stops: chain quads),transformat paint time only (no layout),visibility,opacityunchanged,letterSpacing/textTransform/textDecoration/fontStyle/weights 300–800 in text runs,cursor→CursorStyle,pointerEvents: noneskips hit-testing. - Motion: animate colour/opacity/transform/size between resolved styles with
transitionms +transitionEasingbezier; 0 ms when the OS asks for reduced motion. - Conditions: the core flattens with the surface size, hover capability (true on desktop), reduced motion, and the node’s states (
hover,focus-visible= focus from keyboard,pressed). - Natives: the round-1 list above, menus with submenus, Toast layer, Table with windowing, CodeBlock with the core tokenizer, Chart with
nice_ticksand the chart palette, FileUpload drop (gpui file drop events) + host callback. - Keyboard per
a11y.json; AccessKit roles fromaccessibility.role/states where available; mirrorrtlMirroredIconsunder rtl.
SwiftUI (iOS)
Section titled “SwiftUI (iOS)”- Conditions: surface size from
GeometryReader/onGeometryChange,horizontalSizeClassis NOT the rule (use px vs$breakpoint), hover = iPadOS pointer (.onHoveravailable), reduced motion =accessibilityReduceMotion. - Keys: per-side widths via an overlay
Path(one colour), dashed viaStrokeStyle(dash:), per-corner radii viaUnevenRoundedRectangle, gradients viaLinearGradient(angle → unit points),transformvia.offset/.scaleEffect/.rotationEffect(paint-only by construction),visibility: hidden=.opacity(0)+.accessibilityHidden,pointerEvents: none=.allowsHitTesting(false),cursorignored (pointer:.pointerStyleon iPadOS 17+ optional). - Motion:
.animation(.timingCurve(x1, y1, x2, y2, duration:), value:). - Natives: Form, NumberField (Stepper + TextField with
.numberformat in the surface locale), ChipInput, DateRangePicker (MultiDatePickeris not a range — custom grid), TimePicker (list/menu), FileUpload (fileImporter+ drop), CodeBlock (core tokenizer via FFI or a Swift port), ContextMenu (.contextMenu), Toast (overlay layer), Table (LazyVStack windowing), Charts (Swift Charts withniceTicksvalues). - Locale:
Locale(identifier:)for formatters; week start fromcatalog/locale.jsondata (do NOT trustCalendar.current), direction fromrtlLanguages. - VoiceOver per
a11y.jsonand each node’saccessibility(accessibilityAddTraitsfrom role/states,.isHeader+ level,accessibilityValuefor progress,autoFocusvia@AccessibilityFocusState; adjustable actions for Slider/NumberField),AccessibilityNotification.Announcementforannounce,@AccessibilityFocusStateforfocus;.flipsForRightToLeftLayoutDirection(true)only onrtlMirroredIcons.
Compose (Android)
Section titled “Compose (Android)”- Conditions:
BoxWithConstraints/onSizeChangedon the SURFACE, hover =LocalInputModeManager/ mouse presence, reduced motion =Settings.Global.ANIMATOR_DURATION_SCALE == 0. - Keys: per-side widths + dashes via
drawBehind(PathEffect.dashPathEffect), per-corner radiiRoundedCornerShape(topStart…), gradientsBrush.linearGradient(angle → offsets in the size),transformviagraphicsLayer(paint-only),visibility: hidden=alpha 0+clearAndSetSemantics {},pointerEvents: none= no pointer input,cursor=pointerHoverIcon. - Motion:
animate*AsState(tween(ms, easing = CubicBezierEasing(x1, y1, x2, y2))). - Natives as for SwiftUI (Material 3
DateRangePicker,TimePicker-like list,ExposedDropdownMenuBoxfor the searchable Select,LazyColumnfor Table windowing,DropdownMenu+ long-press for ContextMenu,SnackbarHostsemantics for the toast layer). - Locale:
java.text/android.icuformatters in the surface locale; week start from the generated table;LayoutDirectionfromrtlLanguages. - TalkBack per
a11y.jsonand each node’saccessibility(semantics { role; stateDescription; heading(); selected; liveRegion }),View.announceForAccessibilityforannounce,FocusRequesterforfocusandautoFocus; mirror onlyrtlMirroredIconsunderLayoutDirection.Rtl.
10. Breaking changes and migration
Section titled “10. Breaking changes and migration”- Table is a NATIVE with object columns/rows. Old surfaces
(
columns: ["Name"],rows: [["Alice"]]) fail validation; migrate tocolumns: [{key: "name", label: "Name"}],rows: [{name: "Alice"}]. - Sparkline is a core macro: an extension may no longer define one
(the example extension’s native is now
TrendLine). - Style:
":hover"and":focus-visible"are valid (were refused);@media (max-width: N)means< N. - Functions:
functionNames= the basic 14, then the 15 core names (renderers must implement them;setis an action);bindFunctionNamesis what a bind-time resolver needs (adds and/or/not/required).eqnow equates a number with a string spelling it. - Bind pass: Table slot cells are no longer bound against the surface
(the pass leaves them for per-row binding); literal Table
rowsare never interpreted;accessibilityresolves.bind-time.jsondatasets are 3 per case and the file gainsextra. - Expanded tree: macro parts carry
accessibility(from$a11y); Pagination’s prev/next uselt(disabled at the ends for any page beyond them) and writeclamped pages. - a11y.json:
roleis now machine-readable (prose moved tonotes), plusroles. - strings.json:
ofremoved; 9 ids added. locale.json:languageAliases,rtlMirroredIcons, ~90 likely regions. - Actions may carry
eventANDfunctionCall(only the expander emits both today). - UiNode may carry
visible; style values may be dynamic after expansion;template.key;accessibilityfrom the flat path. - Recipes:
recipeParts()lists 82 components; neutral recipes for Table moved to the native’s parts. - Rust:
macros.jsonnow hasslotsvalues that are objects and$setkeys —MacroTemplatemust accept them before the crate can load the embedded table. Checked here:cargo check -p eui-core --libcompiles against the regenerated mirror;tests/generated_drift.rsstill asserts 61 components (now 82) andstyle_check.rsthe old media pattern — both are the Rust lane’s to update.
11. Deferred (not in round 1)
Section titled “11. Deferred (not in round 1)”- Per-side border COLOURS (gpui limit) and
zIndex— kept out on purpose. - A DnD reorder contract for List/Table rows, column resizing and pinning.
- Chart: stacked area, axis formatters per locale beyond
formatNumber, log scales. Smallest next step: aformatprop taking a basic-catalog format call. - Form: async (server) validation round trip; smallest next step: an
errorsbindable prop on Form the host fills aftersubmit. - Combobox creatable entries (Select
allowCreate). - Locale-aware string pluralization of the built-in strings (only
{name}placeholders today). - Sidebar’s own narrow-screen navigation (a built-in drawer): one slot
cannot render in two places without duplicating ids and state. Smallest
next step: a native
Sidebar(or aDrawermode that renders inline above a breakpoint) so ONE subtree moves between the column and a sheet. - Multiple functions per Action (an author function AND the
$setwrite): needs an Action shape beyond A2UI’s singlefunctionCall; reported instead.