eUI round 2 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 2 adds the authoring features round 1 left out, settles the open
contract questions in TS and Rust together, and turns the 53 gpui-vs-web
divergence causes into decisions. This file is the CONTRACT half: what
changed in @exponential_app/eui and what each renderer implements to match.
Everything here is locked by a fixture or a test (src/round2.test.ts).
Read §0, the sections you own, then §9 (your checklist) and §10.
0. What moved
Section titled “0. What moved”| area | change | source of truth | locked by |
|---|---|---|---|
| Resizable | new native: panels + handles, bound sizes, limits, keys |
core.catalog.json, src/resizable.ts |
fixtures/resizable.json |
position: sticky |
pinned inside the nearest scroller | catalog/style.json |
style.test.ts, round2.test.ts |
backdropBlur |
$blur.<name> only; new token group blur |
style.json, tokens.json, themes |
theme-resolved.json, round2.test.ts |
animation |
8 keyframe sets, motion-token timing, reduced motion | style.json animations, src/animation.ts |
fixtures/animations.json |
direction |
on ANY node, inherited; textAlign start/end per node | style.json, src/direction.ts |
fixtures/text-direction.json |
| Formatter | host-supplied number/currency/percent/date/relative time; the English fallback takes the surface zone as a UTC offset | src/format.ts, locale.json englishFallback |
fixtures/format.json |
| host API | surface: settings (+ timeZone), Formatter methods, commands (+ scrollToIndex); version 2 |
catalog/host.json |
host.test.ts |
| display of non-strings | a bound number in a text prop shows 412 |
src/format.ts displayString |
format.json display |
| built-in strings | 8 ids for the labels renderers still hard-coded | catalog/strings.json |
round2.test.ts |
| templates | template nodes LIFTED out of the tree into templates; self-instantiating ones reported, kept in place |
src/reducer.ts |
template-items.json, kitchen-sink.expanded.json, catalog-basic-map.json |
| keys | missing/empty/duplicate keys → #<index>; suffixes accumulate, ./~ escaped |
src/list.ts |
template-items.json |
| lists | one-axis window, sections + sticky headers, scrollToIndex |
src/list.ts, a11y.json |
fixtures/virtual-list.json |
| parts | every laid-out part is a recipes part, dumped as <id>.<part>[.<i>] |
catalog/recipes.json |
theme.test.ts, the harness |
| layout numbers | field/media intrinsic sizes, window, Resizable, TreeGuides | catalog/layout.json |
generated constants |
| Video / Image / Chart / overlays | explicit sizes instead of browser defaults | core.catalog.json, layout.json, §7 |
conformance-known.json |
| conformance | manifest v2 (24 suites), layout-box dump, real Nunito + Fira Code, 53 decisions | src/conformance.ts, conformance/ |
manifest.json, compare.test.ts |
| prompt | teaches sticky, backdropBlur, animation, Resizable, sections | src/prompt.ts |
prompt-budget.json (6475 / 6500) |
Counts: 83 components (47 natives, 36 macros), 31 functions (17 core), 65 built-in strings, 16 token groups.
1. Resizable
Section titled “1. Resizable”{ "id": "split", "component": "Resizable", "children": ["nav", "main"], "direction": { "base": "vertical", "md": "horizontal" }, "sizes": { "path": "/ui/split" }, "panels": [{ "min": 20 }, { "min": 30, "collapsible": true }], "handle": true, "style": { "height": 480 } }| prop | type | meaning |
|---|---|---|
direction |
horizontal | vertical, responsive | panels side by side or stacked |
sizes |
[number], bindable, two-way |
percent per child (sum 100) |
panels |
[panel {min?, max?, collapsible?}] |
limits by index; min default 10 (layout.json panelMin), max 100 |
handle |
boolean | draw a grip on each handle |
style |
style | the group box (give it a height) |
Event change {sizes} after every drag end or key. A bound sizes is
written back (two-way, like every bindable native).
Numbers (src/resizable.ts, all percent, rounded to 1e-6):
| function | rule |
|---|---|
normalizeSizes(sizes, count, panels) |
valid given sizes kept, missing ones share the rest of 100 (equal split when none given), scaled to 100, clamped to [min, max] (a collapsible panel may stay 0), the difference handed to panels with room in index order; a min above 100/count drops to it |
resizePanels(sizes, handle, delta, panels) |
from the sizes at the START of the drag; only panels handle and handle + 1 change; clamped by both panels’ limits; a collapsible panel dragged below half its min collapses to 0; a collapsed one dragged past half its min reopens at max(min, delta), clamped like any drag |
keyboardResize(…, key, orientation, direction) |
arrows move the handle on screen by resizeStep (10): horizontal Left/Right (rtl: ArrowRight shrinks the first panel), vertical Up/Down; Home/End = first panel to its min/max; Enter toggles collapse of the first panel if collapsible, else the second |
panelExtents(sizes, container, handle) |
px = (container − handles × $control.hairline) × size / 100 |
dragDelta(px, container, count, orientation, direction) |
pointer px → percent (rtl horizontal flips) |
Layout: a flex row (column) of panels and handles; every handle is
$control.hairline thick in layout with an 8 px hit area
(resizeHandleHit) centred on it; panels stretch across. Recipes:
Resizable/root|panel|handle|grip, when: {direction, handle}; neutral:
handle $color.border → $color.ring on hover/focus/pressed, grip a 6×24
pill (24×6 vertical). A11y: role group; each handle a focusable
separator (orientation of the line, valuenow = the panel before it,
valuemin/max = its limits, controls = that panel), named $string.resize.
2. Style
Section titled “2. Style”| key | values | rule |
|---|---|---|
position |
+ sticky |
relative in layout, then pinned by its top/bottom/left/right/inset* inside the nearest scrolling ancestor (overflow scroll/auto, else the surface scroller) while its parent is in view |
backdropBlur |
$blur.sm|md|lg|xl only |
blurs what is BEHIND the box through its translucent background. No platform blur → paint the background alone (gpui today), so authors pair it with a translucent backgroundColor. CSS: backdrop-filter + -webkit- |
animation |
pulse spin fade-in slide-in-up/down/left/right shimmer | style.json animations |
animationDuration |
$motion.<name> only |
replaces the set’s duration token (factor kept) |
direction |
ltr | rtl, ANY node | inherited; flex order, logical insets/paddings, mirrored glyphs, textAlign start/end and the bidi paragraph direction follow the node’s resolved direction |
Recipes may set backdropBlur and animation too. Tokens: blur
(neutral: 4 / 12 / 16 / 24 px; the others inherit).
Animations (src/animation.ts):
| name | keyframes | duration | easing | runs | reduced motion |
|---|---|---|---|---|---|
| pulse | opacity 1 → 0.5 → 1 | $motion.slow × 7 |
$ease.standard |
∞ | opacity 1 |
| spin | rotate 0 → 360° | $motion.slow × 4 |
linear | ∞ | 0° |
| fade-in | opacity 0 → 1 | $motion.standard |
$ease.decelerate |
1 | end state |
| slide-in-up/down/left/right | opacity 0 → 1, translate 8 px → 0 (up = from below, left = from the right) | $motion.standard |
$ease.decelerate |
1 | end state |
| shimmer | a band (background colour, alpha 0 → .5 → 0) sweeps −1 → 1 box widths, clipped to the box | $motion.slow × 5 |
linear | ∞ | no band |
Channels compose OUTSIDE the node’s own transform, about the box centre;
opacity MULTIPLIES the node’s own (paintedOpacity: own 0.5 + pulse →
0.5 … 0.25; a finished fade-in keeps the author’s 0.6). Channels are
physical: direction never mirrors them (rtl too: slide-in-left moves
left, the shimmer sweeps left → right). Easing applies per keyframe segment
(CSS semantics). An animation starts when the node enters the tree (first
paint, visible turning true, a template item appearing).
CSS: @keyframes xui-<name> (keyframesCss) + animationCss(name, animationTiming(name, theme)), using the individual translate/rotate/
scale properties. Opacity keyframes move --xui-a-opacity, and
styleToCss writes opacity: calc(<own> * var(--xui-a-opacity, 1));
register both custom properties once per document with
ANIMATION_PROPERTIES_CSS (unregistered, they jump instead of
interpolating).
Text alignment (src/direction.ts): nodeTextAlign = style
textAlign, else a Text’s align, else start; start/end resolve against
the NODE’s direction (physicalTextAlign). The Rust core’s Visual carries
the resolved physical align; gpui and the natives shape text with the
node’s direction as the bidi paragraph direction.
3. Formatting
Section titled “3. Formatting”ONE Formatter per surface, from SurfaceSettings.locale and the new
timeZone (IANA; default the platform’s, UTC in fixtures). The zone is the
HOST’s: it builds the formatter (or, for the Rust fallback, the UTC offset)
in it. The Rust core has no zone database and no zone setting: the zone
reaches it only through the formatter (FFI: HostFormatter, or HostZone
for the fallback; FfiSettings.timeZone is kept for the host):
interface Formatter { readonly locale: string number(value: number, o?: { decimals?: number; grouping?: boolean }): string // 0..3 digits by default currency(value: number, code: string, o?: { decimals?: number; grouping?: boolean }): string // the code's minor digits percent(value: number, o?: { decimals?: number }): string // 0.256 → 26% date(value: unknown, o?: { format?: string; style?: `short`|`medium`|`long`|`full`; time?: boolean }): string relativeTime(value: unknown, now: number): string // yesterday, in 2 hours plural(value: number): `zero`|`one`|`two`|`few`|`many`|`other`}| platform | formatter |
|---|---|
| TS (React) | intlFormatter(locale, timeZone) (Intl) |
| Rust core | a Formatter trait; default EnglishFormatter over LOCALE_JSON englishFallback, at a host-supplied UTC offset per instant (gpui: the local zone) |
| FFI (Swift, Kotlin) | a foreign Formatter callback interface the host passes in SurfaceSettings (Foundation / java.text·android.icu), else the core’s English one |
Rules: date values are yyyy-mm-dd (a calendar day, no zone), an ISO
date-time (no offset = UTC) or epoch ms; anything else formats to ``.
Instants show in the surface zone; a calendar day never shifts. The English
fallback has no zone database: it takes ZoneOffset = (utcMs) => minutes
(TS englishFormatter(zone), fixedOffset, intlZoneOffset; Rust: the
host passes the platform’s offset). Its patterns, joiners (, / at)
and the unknown-currency template are data (englishFallback
datePatterns, timePattern, dateTimeJoin, unknownCurrency): read
them, never copy them. A numeric string is [ws][+-](https://github.com/Niach/exponential/blob/master/packages/exponential-ui/docs/digits[.digits]|.digits) [e[+-]digits][ws]; hex, Infinity and separators are not numbers.
format = a TR35 subset (y yy yyyy M MM MMM MMMM d dd E EEE EEEE h hh H HH m mm s ss a, quoted literals, ''), ASCII digits, locale names; style =
the locale preset (time: true appends the short time). Relative time
picks the unit by |Δ| (< 1 min seconds, < 1 h minutes, < 1 day hours, < 7
days days, < 30 days weeks, < 365 days months of 30 days, else years),
truncated toward zero, numeric:auto phrases (now, yesterday, last week).
Rounding = half away from zero on the shortest round-trip decimal.
Bind table: bindFunctionNames now includes formatNumber,
formatCurrency, formatDate, pluralize (A2UI basic) and the core
formatPercent{value, decimals}, formatRelativeTime{value, now?}; all run
through the surface Formatter (ResolveOptions.formatter, now). A node
using formatRelativeTime without now re-binds at least once a minute.
pluralize: value 0 with a zero arm → it, else the CLDR category, else
other.
Where renderers format: Table number/currency/percent/date/
relativeTime cells (column.currency, column.decimals), NumberField
display, chart tick labels and value labels, DatePicker/DateRangePicker/
TimePicker trigger text, calendar month and weekday names (date(…, {format: "MMMM yyyy"}), "EEE"), the format functions.
Display of non-strings (displayString): a string/markdown prop
bound to a number shows ECMAScript Number.prototype.toString (412,
1.5, 0.30000000000000004, 1e+21, -0 → 0), booleans true/false,
nothing for null/objects/arrays/NaN. No locale (use formatNumber).
Strings: invalidValue (a failed check without a message), message
(the Composer field’s name), codeBlock (an untitled CodeBlock region),
dialog (an untitled Dialog/Drawer), table (an uncaptioned Table),
carousel / slide (role descriptions), resize (a Resizable handle).
Replace every hard-coded English label with them: React natives/shared.tsx
Invalid value, inputs.tsx Composer aria-label, code.tsx region
label, overlays.tsx title-less DialogPrimitive.Title {kind}, media
aria-roledescription; gpui paint.rs Table/FileUpload component-name
labels, paint/date.rs MONTH_NAMES (→ Formatter); Compose
Semantics.kt/NodeInfo.kt Loading ($string.loading),
SurfaceMeasurer.kt/TextFields.kt Message, MediaLeaves.kt Page N
(pageOf); SwiftUI NodeInfo.swift/NodeView.swift Loading,
ControlLeaves.swift/SurfaceMeasurer.swift Message, MediaLeaves.swift
Page N.
Conformance replays format.json through the English fallback
(exact). A platform Formatter (Foundation, android.icu) may run too, after
mapping U+202F and U+00A0 to a space on both sides: newer ICU puts U+202F
before AM/PM. zoned cases carry timeZone (ICU) and offsetMinutes
(fallback).
4. Templates, keys, rows
Section titled “4. Templates, keys, rows”Lifted templates. A node a template.component names is the TEMPLATE:
the reducer lifts it out of every children list and slot (any depth, root
excepted) into ReduceResult.templates[id], validated and expanded like the
root, in discovery order; it never also renders in place. The flat path
builds referenced ids eagerly; an id nobody defines is an issue
(template: no component with this id). Nested templates are lifted too.
Cycles. A template that would instantiate itself is an issue
(template: cycle through this id) and is NOT lifted: it stays where it was
and its owner renders no items. That covers a template naming its own owner,
an ancestor of its owner, the root (issue on the root id), and loops through
other templates (each one on the loop is reported).
kitchen-sink.expanded.json gains templates: {"list-item": …} and loses
the in-place list-item.
Keys (templateItemKeys): the value at template.key (a pointer
relative to the item; objects as JSON) — #<index> when missing, null, ``
or a DUPLICATE of an earlier key, more #s until unique; no key = the
index. Table rows: tableRowKeys(rows, rowKey = "id"), same rules.
Instance ids: every node of an item is painted as <node id><suffix>,
suffix = the enclosing item’s suffix + .<key>; it ACCUMULATES through
nested templates (issue.ops.1). The key is escaped first
(instanceSegment: ~ → ~0, . → ~1), so a.b + c (.a~1b.c)
and a + b.c (.a.b~1c) differ. The Rust item_suffix appends, never
replaces. Table part ids keep the raw row key.
List sections: sectionBy (a field) groups CONSECUTIVE template items
(data order kept); slot section is row-scoped (List slotScope: row) and
binds once per section with the literal scope {value, count, index}
(sectionScope / bindSectionHeader); stickyHeaders pins the current one.
A header is a heading (level 3) in the a11y tree.
Rust, same pass: layout reads props through resolve_node_props (a
literal Table rows holding {path} is DATA, bind-time.json
Table/slot-cell:literal-rows); reconcile.rs and gpui events/paint show
non-string text values with display_string, never as_str; the FFI
exposes bindRowSlotJson(slot, rowsProp, rows, index, data, options) → node | null and bindSectionHeaderJson(slot, section, index, data, options).
5. Virtual lists
Section titled “5. Virtual lists”One axis, vertical or horizontal (direction). Offsets start at the list’s
content start (the inline START of a horizontal list: its right edge in
rtl, whatever sign the platform’s scroll position uses); item i starts at Σ(extentⱼ + gap) for j < i, where a
divided list adds $control.hairline to the gap (the divider centred in
it). Unmeasured items take the MEAN extent of the measured items once any
is measured (the content length stays stable while rows measure in), else
$control.row (itemExtents(measured, row); windows cases with
measured + row).
| function | rule |
|---|---|
virtualWindow(extents, gap, scroll, viewport, overscan = 5, offsets?) |
every item intersecting [scroll, scroll + viewport) (an item ending exactly at scroll counts) ± windowOverscan; before/after spacers. Pass cached itemOffsets (rebuilt only when an extent changes): two binary searches per frame |
scrollOffsetForIndex(…, index, viewport, scroll, align, inset, offsets?) |
start / center / end / nearest (default: the smallest move; none when visible); inset = the pinned header’s extent; clamped to the content |
scrollOffsetForItem(rows, rowExtents, gap, dataIndex, …, stickyHeaders) |
a sectioned list: the DATA index → its flat row (absent = no move), inset = its own section header’s extent when sticky |
listSections + sectionRows |
the flat row list: a header before each section |
stickyHeader(rowOffsets, rowExtents, headerRows, scroll) |
the last header at or before scroll pins at min(scroll, nextHeaderStart − extent); rendered even outside the window |
Windowing starts past windowThreshold (50). A list without a bounded size
windows against its nearest scrolling ancestor (overflow auto/scroll AND
taller content) or the host viewport. scrollToIndex {id, index, align?}
(a host command, a11y.json) takes the DATA index (before a local sort),
renders the item first, subtracts the pinned header. Items keep their
position in the whole list for assistive tech (setsize/posinset).
Bench (fixtures/bench-list.json): 100,000 ListRows on a 390×800
surface, neutral theme; report firstPaintMs, scrollStepMs (100 viewport
steps), scrollToIndexMs (index 50000, start), renderedItems in each
renderer’s README with the machine.
6. Parts (conformance coverage)
Section titled “6. Parts (conformance coverage)”catalog/recipes.json native.<C>.parts lists EVERY part a painter lays
out. New: Switch/body, Radio/items, Slider/header, Table/body,
CodeBlock/body + code, FileUpload/title + browse + fileIcon, NumberField/input,
ChipInput/input, Tooltip/anchor, Accordion/count, List/section, Resizable/*.
| rule | |
|---|---|
| id | <node id>.<part> (code.header) |
| per item | .<index> (Radio item.0, Tabs tab.1, Accordion trigger.1, FileUpload file.0, ChipInput chip.0, CodeBlock line.3, List divider.1 = before item 1, Table headerCell.2) |
| Table rows | .<row key> (row.alice, checkbox.alice, cell.alice.0, checkbox.header) |
| web | data-xui-id + data-xui-part on the part’s element |
| paint parts (never dumped) | Switch/thumb, Slider/thumb + range, Ring track/fill, focus rings, arrows, decorations |
7. Explicit sizes (no browser defaults)
Section titled “7. Explicit sizes (no browser defaults)”catalog/layout.json (generated as layoutConstantNames/Values,
LAYOUT_JSON): windowThreshold 50, windowOverscan 5, resizeStep 10,
panelMin 10, resizeHandleHit 8, fieldIntrinsicWidth 160,
mediaIntrinsicWidth 320, mediaAspectRatio 1.7777778,
treeGuideColumn 16.
| component | contract |
|---|---|
| overlays (Dialog, Drawer, Sheet, AlertDialog, Popover, HoverCard, Tooltip, DropdownMenu with a trigger slot) | LAYOUT-TRANSPARENT: the frame is the trigger’s; the trigger is the flex item (a Link stretches in a stretch row, a Button keeps its control height); nothing without a trigger |
| DropdownMenu default trigger | outline button: icon (when set) + label, no chevron |
| Video | aspectRatio prop (default 16:9): height = width / ratio; max-content width 320, min-content 0 |
| Image | aspectRatio, else 16:9 without a height; the box never waits for the picture; Image/fallback fills it, glyph $control.iconLg centred |
| Chart | height = the WHOLE box (title, legend, axes inside); min-content width 0 (shrinks in a row) |
| AudioPlayer | title line (AudioPlayer/track) + $spacing.xs + controls row (AudioPlayer/controls = $control.row) |
| text fields (Input, Textarea, NumberField, ChipInput) | max-content width 160 |
| picker triggers (Select, DatePicker, DateRangePicker, TimePicker) | content-sized (text + glyph + padding + border), no 160 floor |
| Badge, Pill | 2 × paddingHorizontal + content; an absent $if part adds no gap |
| ToggleGroup | content-sized (align-self start) unless fill |
| TreeGuides | depth × 16 wide, stretched to its row; TreeGuides/line width = the stroke |
| Slider | header + $spacing.xs + a track row as tall as the thumb; the 6 px rail centred |
| Radio | Radio/root gap; an option row = max(dot, label line) |
| Table | header (cell + hairline) + rows (cell + hairline) + caption (line + 2 × paddingVertical) |
| CodeBlock | border + header (recipe padding + copy button + hairline) + lines × lineHeight; padding only from recipes. The header’s title part = title, else the language id (ts; none for plain), so a header never stands empty |
| FileUpload | the drop zone holds icon, title, hint and the Browse button ($control.buttonSm, outline) |
| Text, Markdown | an empty text = 0 lines; a paragraph = its runs, no extra line |
| Switch | [label + description][switch]: label first, the track at the end; the row toggles |
| Checkbox, Radio | control first, then the label column; never overlapping |
| Accordion | count = its own muted part after the title |
| Carousel | only the active page is placed; the others are aria-hidden + inert, may stay mounted, carry data-xui-inactive. 2+ pages: Carousel/root gap + a centred controls row = previous + indicator (the dots) + next, spaced by Carousel/controls gap; the buttons’ size from their recipes (28 × 28), no CSS literals |
| text in fields | starts at border + paddingHorizontal; no painter padding |
Painter asks (VAPP-100)
Section titled “Painter asks (VAPP-100)”What the core hands every painter so none re-derives it:
| item | contract |
|---|---|
FfiTextStyle.letterSpacing, textTransform, fontStyle |
resolved like the other text fields (letter spacing × font scale); measure and paint with them |
FfiLeaf.ownerComponent |
the native or MACRO owning a part leaf (Tabs, Stepper); null for a plain node |
FfiLeaf.text |
a number or boolean in the text prop = its display string (§3) |
Surface.effectiveTheme() |
the Theme the core resolves against: extends, density and contrast applied |
setHovered(ids) / setHover(id, on), FfiNode.hovered + interactionStates |
the host’s pointer hover; recipes’ hover and :hover resolve through it |
FfiNode.hoverStyled (Rust Surface::hover_styled) |
the node restyles under the pointer: a :hover style block, or a recipe rule on state: hover its props match, in the effective theme. A painter tracks a mouse over these nodes (and pressables, triggers, fields) and reports the hover; re-read after a theme switch |
markDirty(index) after a load |
a Markdown is measured again; an Image box keeps its ratio’s height |
| Stepper | number and label carry the step status ($recipe): the current number takes the marker’s ink (primaryForeground), an upcoming one and an upcoming label are muted |
8. Conformance
Section titled “8. Conformance”- Manifest v2 (
CONFORMANCE_VERSION2, 24 suites, 1642 cases): new suitesbind,style-conditions,code-tokens(round 1’s, now counted) andformat,template-items,text-direction,resizable,virtual-list,animations. Runners read the version from the manifest. - Dump (
conformance/dom.ts): frames are LAYOUT boxes (LAYOUT_ONLY_CSSturns transforms and animations off while measuring);[data-xui-inactive]subtrees are skipped. - Fonts: Nunito (7 static instances of Google Fonts’ variable font) and
Fira Code 6.2 (4 weights) under
conformance/fonts/with their OFL texts;fonts.jsonmaps them; not shipped in the npm package. Licence row: see the report. - Kitchen sink: a
panelsResizable is the last section (the kitchen sink uses every visible component). - Decisions:
fixtures/conformance-known.jsoncauses(53 origins of the 2026-10-08 run) +rules(decisions with no origin today). The gpui ratchet reads onlycases; its writer must keepcausesandrules.
| group | nodes | right | fix |
|---|---|---|---|
| overlay trigger rows | resp-drawer, menu, hover-card(-trigger), alert-dialog, dialog, drawer, sheet, popover, tooltip | layout-transparent (§7) | React (wrappers fill/stretch, menu chevron); gpui (Link trigger stretch) |
| media sizes | audio, page-1, video | §7 | React (audio row, video ratio); gpui (Image ratio, video ratio) |
| chart heights | chart, chart-line, chart-donut | gpui (height = whole box) |
React |
| control heights | code, table, nf-files | gpui | React |
| control heights | form-plan, form-volume | web | gpui |
| picker row widths | form-due, form-sub | web | gpui |
| badge and pill widths | hdr-badge (+ hdr-title/sub), nav-inbox.count (+ label), resp-nav-inbox.count (+ label), pill-1 (+ pill-2..4, footer-spacer) | web | gpui |
| empty and number text | list-item.meta., list-item.body.title(.) | web | core + gpui (display_string, 0-line empty text); contract (lifted template) |
| sparkline shrink | sparkline (+ spark-label, progress-bound.*) | web | gpui (Chart min-content 0) |
| toggle group stretch | toggles | web | gpui |
| tree guides | tree-guides | gpui | React |
| card row | card-row.body.title/subtitle | web | gpui (a sibling part is 30 px wider) |
| markdown lines | main-md (+ nav-card) | web | gpui |
| transforms | tab-bar.item.1.icon | gpui (paint-only) | the dump (done) |
Rules without an origin today: Switch order (React), Checkbox overlap (React), field text inset 14 vs 23 (gpui), Accordion count (gpui + React), image fallbacks (both), carousel pages (React marks, dump skips), rtl bidi of trailing punctuation (gpui), resp-states line breaks (survivor: UAX #14 both sides, inside the ±1 line tolerance), part coverage (React).
Ratchet procedure once React and gpui land: bun run --filter @exponential_app/eui conformance -- --write-baseline (intended web changes:
lifted template, Resizable, layout-box dump, Nunito/Fira Code, the React
fixes), then EXP_UI_WRITE_FIXTURES=1 cargo test -p eui-gpui --test conformance (keeps causes/rules), target zero origins; a
survivor gets fix: ["survivor"] + reason.
Shots: specimens.json now gives the round-1 containers real content
(Sidebar nav, AppBar action, AlertDialog/HoverCard triggers, ContextMenu
target, Form fields, ScrollArea rows, Resizable panels). View-catalog ids
to add (web + android here, ios + desktop on the Mac): exponential-ui-
scroll-area sidebar app-bar code-block kbd label breadcrumb tab-bar stepper alert-dialog hover-card context-menu toast sparkline form number-field rating chip-input date-range-picker time-picker file-upload resizable.
9. Per-renderer checklists
Section titled “9. Per-renderer checklists”React (@exponential_app/eui-react)
Section titled “React (@exponential_app/eui-react)”- Resizable native (flex + hairline handles, pointer capture from the drag START sizes,
keyboardResize,normalizeSizes, two-waysizes, separator a11y, recipes incl. grip). position: sticky(CSS),backdropBlur(styleToCssalready maps it),animation(ANIMATION_PROPERTIES_CSS+keyframesCssonce per document, replacing the own--xui-bandrule;styleToCss(…, theme)multiplies opacity; drop it under reduced motion),directionon inner boxes (the CSS cascade does it; mirror glyphs by the node’s direction).- Formatter: build
intlFormatter(locale, timeZone)per surface, pass it to the bind pass (formatter), use it for Table cells (newcurrency/percent/relativeTimetypes), NumberField, chart ticks, pickers, calendar names. - Strings: replace every hard-coded label with the eight ids of §3.
- Templates: read
templatesfrom the reduce result (dropfindNodein-place lookup); keys already#<index>. - List:
sectionBy+sectionslot (bindSectionHeader),stickyHeaders, horizontal windowing with cached offsets, gap/divider in the offsets,scrollToIndexcommand (scrollOffsetForItemwhen sectioned), setsize/posinset; bench → README. - Parts:
data-xui-id="<id>.<part>[.<i>]"+data-xui-parton every part of §6;data-xui-inactiveon inactive carousel pages. - §7 fixes on the web side: overlays layout-transparent (wrapper
display: contentsor trigger-sized), DropdownMenu without chevron, Chart frame =height, AudioPlayer controls row, Videoaspect-ratio, Table/CodeBlock spacing from recipes only, FileUpload Browse button, TreeGuides columns, Switch label first, Checkbox no overlap. - Regenerate the web baseline after 1–8 (§8).
Rust core (eui-core) + FFI
Section titled “Rust core (eui-core) + FFI”- Load the new catalog: Resizable, List
slotScope: row+sectionslot,paneldef, cellType, VideoaspectRatio, 31 functions;generated_drift.rscounts (83 components);layout.json(LAYOUT_JSON). theme.rs: token groupblur(field,NUMBER_GROUPS, completeness, overlay); recipe keysbackdropBlur(token-onlyblur) andanimation(enum); replaytheme-resolved.json.style_check.rs:directionno longer root-only (spec-driven),sticky, the two token keys;animationstable (style.json) →animation.rsport (timing,cubic_bezier, frames) replayinganimations.jsonwithin 1e-3.format.rs: theFormattertrait +EnglishFormatterfromenglishFallback(patterns, joiners, unknown currency READ from it; a UTC-offset-per-instant zone; the numeric-string grammar); replayformat.jsoncalls+zoned; the six format functions in the bind table through the surface formatter (the host’s, in its zone;ZonedEnglishFormatterat the host’s offset);display_string; replayformat.json.reducer.rs: lift templates intotemplates(nested + flat, nested templates, the missing-id issue, cycles kept in place); replaytemplate-items.jsonreduce,catalog-basic-map.json,kitchen-sink.expanded.json.list.rs: keys (#<index>),table_row_keys,item_extents(unmeasured = the measured mean, else$control.row;ListWindowthe same),item_suffixaccumulation with the~0/~1escape,virtual_window+scroll_offset_for_indexover cached offsets (binary search),scroll_offset_for_item, sections + sticky; replaytemplate-items.json,virtual-list.json.resizable.rs: the five functions (reopen at max(min, delta)); replayresizable.jsonwithin 1e-6.animation.rs:keyframes_csswrites--xui-a-opacity,ANIMATION_PROPERTIES_CSS,painted_opacity; replayanimations.jsoncss,properties,opacity.direction: per-node direction + physical align intoVisual; replaytext-direction.json.data.rs/reconcile.rs: layout viaresolve_node_props;display_stringinstead ofas_strfor text values.host/contract.rs: host.json version 2 + thesurfacesection.- FFI:
bindRowSlotJson,bindSectionHeaderJson,scrollToIndexcommand, a foreignFormatter+FfiSettings.timeZone(the host’s; the core’s fallback gets the host’s UTC offset throughHostZone); regenerate bindings (generate-bindings.sh); bench 100k (bench.rs) → README.
gpui (eui-gpui)
Section titled “gpui (eui-gpui)”- Resizable (drag, keys, AccessKit separator), sticky (paint offset inside the scroller), backdropBlur (fallback: background only, documented), animations from the core frames (reduced motion = rest).
- §7 gpui-side fixes: Image/Video ratio, Chart min-content 0, picker triggers without the 160 floor, Radio gap, Slider row height, Badge/Pill no gap for absent parts, ToggleGroup content-sized, Link trigger stretch, Markdown paragraph lines, empty text = 0 lines, number text via
display_string, Accordion count part, field text inset, bidi paragraph direction = node direction, the CardRow part. - Formatter: core
EnglishFormatterat the local UTC offset (or a host one) for Table cells, NumberField, chart ticks,paint/date.rsmonth names; strings for the Table/FileUpload labels; painted opacity = own × frame. - List: horizontal windowing, sections + sticky headers,
scrollToIndex, unbounded-height windows against the scroller; bench headless → README. - Ratchet: keep
causes/ruleswhen writingconformance-known.json; lower the counts.
SwiftUI (Mac run)
Section titled “SwiftUI (Mac run)”- Resizable (
DragGesturefrom the start sizes,accessibilityAdjustableActionon handles), sticky (pinnedViews/ offset), backdropBlur (.background(.ultraThinMaterial)by radius: sm/md thin, lg/xl regular; else background only), animations (TimelineViewwith the core frames;accessibilityReduceMotion= rest). - Formatter: a Foundation formatter over the FFI callback interface (
NumberFormatter,Date.FormatStyle,RelativeDateTimeFormatterwith the core’s unit choice), locale + timeZone from settings. - Templates from
templates; keys/suffixes from the core; List sections (Sectionheaders, pinned),ScrollViewReader.scrollToforscrollToIndex. - Strings (
Loading,Message,Page N, §3), parts, §7 sizes, Switch label first; captures for the 22 specimen ids (§8).
Compose
Section titled “Compose”- Resizable (
draggablefrom the start sizes, semanticssetProgress), sticky (stickyHeaderin LazyColumn, offset elsewhere), backdropBlur (API 31+RenderEffecton a background copy, else background only), animations (rememberInfiniteTransitionfrom the core frames; animator scale 0 = rest). - Formatter over the FFI callback (
android.icu.text.NumberFormat,DateFormat,RelativeDateTimeFormatter), locale + timeZone from settings. - Templates from
templates; LazyColumn/LazyRow keys = the core keys;stickyHeadersections;LazyListState.scrollToItem. - Strings (
Loading,Message,Page N), parts, §7 sizes, Switch label first; JVM bench → README; web + android captures for the 22 specimen ids.
10. Breaking changes
Section titled “10. Breaking changes”directionis valid on any node (was root-only).ReduceResult.templates; a template node listed as a child no longer renders in place; the flat path validates template components.bindFunctionNames+ 6 format functions;functions.names+formatPercent,formatRelativeTime;functions.core17.- List has a row-scoped
sectionslot (rowSlotComponents= Table, List). cellType+currency,percent,relativeTime;column+currency,decimals.- Chart
height= the whole box (round 1 painters differed). - Image without
aspectRatioor a height is 16:9 (round 1 had no rule; the web took the picture’s own ratio once loaded): a portrait or square image given only a width is now cropped or letterboxed (fit). SetaspectRatio. keyframesCssanimates--xui-a-opacity, notopacity; registerANIMATION_PROPERTIES_CSS.- Instance suffixes escape
~and.in keys. - A self-instantiating template is no longer lifted (issue + kept in place).
- Format functions read numeric strings by the §3 grammar (
0x10,Infinity→ ``). catalog/host.jsonversion 2 (surface).- Token group
blur(every root theme must define it); recipe keys +backdropBlur,animation. - Conformance version 2;
conformance-known.jsongainscauses/rules. - Strings + 8 ids;
styleToCss(style, fonts, theme?).
11. Deferred
Section titled “11. Deferred”- Per-renderer implementation of everything above (the renderer lanes).
- Backdrop blur on gpui: no backdrop sampling in the pinned gpui; the background-only fallback stands until upstream adds one (smallest step: a gpui
BackdropFilterelement upstream). - A localized string table per locale (strings stay host overrides).
- Calendar-aware relative days (
yesterdayacross midnight in the surface zone): today 24 h buckets; smallest step: acalendarDaysoption onrelativeTime. - Cascading resize (pushing past the neighbour into the next panel): only adjacent panels change.