Skip to content

Ergonomics eval

30 app-building prompts, answered from the catalog prompt, then validated, rendered and scored. Every number below comes from report.json. CI replays the recorded fixtures and makes no model calls.

The corpus is what a product person types: a settings page, an issue list with filters, a device dashboard, a form with validation, a chat, a kanban, a data table, a wizard, an empty state, a phone inbox at four widths.

  • record.ts sends each prompt to Claude with catalogPrompt() as the system prompt and caches the reply as a fixture.
  • score.ts validates every reply with the reducer the renderers use and scores it.
  • render.ts paints it in the React renderer, the Solid renderer and the <exponential-surface> element, measures each one’s boxes and compares them with React.
  • parity.ts measures the sample template against a reference layout on the stored shots.
Terminal window
bun run --filter @exponential_app/eui eval:record # once, needs ANTHROPIC_API_KEY
bun run --filter @exponential_app/eui eval # score the fixtures → eval/report.json
bun run --filter @exponential_app/eui eval:render # web renders, React + Solid + element, at every width (Playwright)
bun run --filter @exponential_app/eui eval:parity # the sample template vs a reference layout

Recorded: 30 through the Claude Code CLI (real usage).

VariantComponentsCharactersTokens (≈)
catalogPrompt()9135,1038,776
{ terse: true }9124,6516,163
{ lite: true }6325,7726,443
{ sections: ["rules", "index", "functions", "style"] }91 as one line each12,0323,008
{ sections: ["rules"] }–2,060515
componentPrompt(name)1344 on average, 2578 at most86 on average

The cheapest route to the same surfaces is the index prompt plus the 56 components the corpus actually used, served one block at a time: about 7,824 tokens against 8,776 for the full prompt.

MetricValue
Surfaces that validate clean16 of 30
Validator issues22
Components per surface12.8 on average, 29 at most
Authored nesting depth5 on average, 9 at most
Output tokens per surface1,985.6

The mistakes, by frequency

IssueCount
props.icon: not an icons.json name7
no component with this id2
props.description: expected a string2
props.title: expected a string2
style.flex: not in the Box style whitelist2
on.back: a function action replaces the two-way set of props.open; write it in that function's handler or use an event1
props.caption: expected a string1
props.fallback: not an icons.json name1
props.items[N].icon: not an icons.json name1
style.:focus-visible.boxShadow: boxShadow: expected $shadow.<name>1
style.borderTopColor: not in the Box style whitelist1
style.borderTopStyle: not in the Box style whitelist1
CriterionExpected byPassedRate
exactly one primary action141071 %
an empty state where a list can be empty9667 %
a loading state22100 %
every field inside a Form9778 %
a List or Table over a data template131292 %
the dialog, drawer or menu asked for66100 %
HabitCount
Horizontal Stacks of controls without align (they stretch)0
Authored Box components (the rule says: layout macros first)9
Surfaces with raw px spacing (rhythm 1)0

Per surface

SurfaceComponentsDepthIssuesFindings
Team settings1760–
Issue list with filters830–
Device dashboard1643dStatus: props.icon: not an icons.json name; drawer: props.title: expected a string; drawer: props.description: expected a string
Sign-up form1060–
Chat1973message_list: style.flex: not in the Box style whitelist; composer_bar: style.borderTopStyle: not in the Box style whitelist; composer_bar: style.borderTopColor: not in the Box style whitelist
Kanban board2270no empty state
Invoices table531markPaid: props.icon: not an icons.json name
Onboarding wizard001root: no component with this id; no primary action; no form fields; 25 unparsable line(s); no root component
Empty board220no primary action
Notification inbox (phone)640no empty state
Profile card1560–
Sign in1371google: props.icon: not an icons.json name
Pricing2960–
File browser1151entryMenu: props.items[1].icon: not an icons.json name; no empty state
New event1250–
Metrics dashboard1240–
Notification preferences001root: no component with this id; no primary action; no form fields; 18 unparsable line(s); no root component
Delete confirmation1381impactRow: props.icon: not an icons.json name
Search results1651emptyState: props.description: expected a string; no List / Table over a data template
Agent progress1151s6: props.icon: not an icons.json name
Invite members1360–
Photo gallery1342viewer: props.title: expected a string; thumb: style.:focus-visible.boxShadow: boxShadow: expected $shadow.<name>
Order tracking1461itemsTable: props.caption: expected a string
Inbox split view1763senderText: style.flex: not in the Box style whitelist; replyBtn: props.icon: not an icons.json name; root: on.back: a function action replaces the two-way set of props.open; write it in that function's handler or use an event
API keys14702 primary buttons (one expected)
Issue detail, loading2690–
Feedback1150–
Now playing1962art: props.fallback: not an icons.json name; queue-section: props.icon: not an icons.json name
Command result1440–
Team switcher740–

Every surface painted by the React renderer, the Solid renderer and the <exponential-surface> element in the exponential theme at its widths. The gaps are the measured distances between the root’s children; the inset is where the first text starts. Solid and the element must measure the same boxes and paint the same pixels as React (the web-parity gate enforces it), so any difference is a finding.

SurfaceWidthPainted nodesHeightGaps between root childrenFirst text insetSolidElement
Team settings1280 px37560 px0 px12 pxequalequal
Issue list with filters1280 px77521 px0 px12 pxequalequal
Device dashboard1280 px98607 px0 px12 pxequalequal
Sign-up form1280 px28464 px0 px12 pxequalequal
Chat1280 px64187 px0 px12 pxequalequal
Kanban board1280 px103522 px0 px12 pxequalequal
Invoices table1280 px74413 px0 px12 pxequalequal
Onboarding wizard1280 px156 px––equalequal
Empty board1280 px12340 px0 px12 pxequalequal
Notification inbox (phone)320 px93593 px0 px12 pxequalequal
Notification inbox (phone)390 px93593 px0 px12 pxequalequal
Notification inbox (phone)768 px93593 px0 px12 pxequalequal
Notification inbox (phone)1280 px93593 px0 px12 pxequalequal
Profile card1280 px26287 px–499 pxequalequal
Sign in1280 px25405 px–447 pxequalequal
Pricing390 px1111403 px0 px12 pxequalequal
Pricing1280 px111597 px0 px12 pxequalequal
File browser1280 px98580 px0 px12 pxequalequal
New event1280 px45646 px0 px12 pxequalequal
Metrics dashboard1280 px1311001 px0 px12 pxequalequal
Notification preferences1280 px156 px––equalequal
Delete confirmation1280 px37230 px0 px12 pxequalequal
Search results1280 px123808 px0 px12 pxequalequal
Agent progress1280 px81647 px0 px12 pxequalequal
Invite members1280 px63691 px0 px12 pxequalequal
Photo gallery1280 px32738 px0 px12 pxequalequal
Order tracking1280 px77618 px0 px12 pxequalequal
Inbox split view390 px72353 px–12 pxequalequal
Inbox split view1280 px72353 px–12 pxequalequal
API keys1280 px62389 px0 px12 pxequalequal
Issue detail, loading1280 px50555 px0 px52 pxequalequal
Feedback1280 px51696 px0 px12 pxequalequal
Now playing1280 px741117 px0 px12 pxequalequal
Command result1280 px66641 px–41 pxequalequal
Team switcher1280 px13296 px0 px163 pxequalequal

Where a renderer differs from React

Nowhere: every surface measured and painted the same on every renderer.

The sample template (a declarative package rendered through the public host API) measured beside a native reference layout of the same page, on the stored shots of both: the Section band every list starts with, and the first row under it. Values read reference vs template; store pixels are 1.25 CSS px; deltas are template minus reference.

PlatformBand heightBand title insetFirst row insetGap under the bandBand pixel diff
Web24.8 px vs 32 px (Δ 7.2 px)10.4 px vs 10.4 px (Δ 0 px)32.8 px vs 18.4 px (Δ -14.4 px)32.8 px vs 28 px (Δ -4.8 px)2.0 %
Desktop (gpui)32.8 px vs 32 px (Δ -0.8 px)9.6 px vs 10.4 px (Δ 0.8 px)11.2 px vs 18.4 px (Δ 7.2 px)17.6 px vs 28.8 px (Δ 11.2 px)2.4 %

Never used by the corpus (35): Resizable, ScrollArea, Sidebar, Group, Kbd, ZoomableImage, Video, AudioPlayer, QrCode, Carousel, Mosaic, Tabs, Accordion, Collapsible, Plan, Pagination, Outline, DockCard, Popover, Tooltip, Progress, Meter, Ring, Spinner, VoiceWave, CallSurface, Sparkline, Toggle, NumberField, ChoiceTiles, SearchList, DatePicker, QrScanner, MessageBubble, Motion.

Every screen of the Exponential app, classified by what the catalog needs before it could be written as a surface. The list stays in lockstep with the view catalog.

ClassScreens
Expressible today89
Needs a layout macro0
Needs an app extension component9
Needs a new native19

Two of the classes also concern the Solid renderer: an app extension component exists for React only and needs a Solid twin; a new native is a painter every renderer adds. A macro expands in the shared reducer, so it costs no renderer work. 28 screens are in those two classes. The <exponential-surface> element paints with Solid's painter and inherits exactly these gaps, with none of its own.

The candidate macros

MacroWhat it isSolid
EmptyState.inlinea one-line empty state INSIDE a Section (the `list` size already covers it; a Section `empty` prop would place it)reducer only
Treerows with drag-to-reorder and keyboard reparenting (the file tree, the PR tree)a Solid painter too

Candidates that already landed in the catalog:

  • Page: a page: title row (back, title, trailing actions) over a padded scrolling body; the AppBar + Stack + style every settings-like surface rebuilds
  • Toolbar: ONE row of controls (search, filters, the primary action) that wraps on phones; align: center implied
  • FieldGroup: fields side by side (Starts / Ends, first / last name) that stack on phones: Grid columns {base: 1, md: 2} with the form rhythm
  • FormActions: the Cancel / Submit row: end-aligned, the primary last, the form's gap above
  • SplitView: list + detail: a Sidebar that shows ONE column on phones with a back action, bound to the open item
  • StatTile: a value with a label and a trend sparkline (the dashboard tile)

Every screen

ScreenClassWithSolid
sign-inExpressible todayCard + Button list (the OIDC providers) + Input + Link; the passkey call is a host function–
onboardingExpressible todayChoiceTiles over Card–
onboarding-create-teamExpressible todayForm + Input + Button–
onboarding-joinExpressible todayForm + Input (the invite link) + Button–
onboarding-inviteExpressible todayCard + CodeBlock (the link) + Button; the copy is a host function–
onboarding-devicesExpressible todaySection + Row + CodeBlock (the install one-liner) + Button–
invite-acceptExpressible todayCard + Avatar + Button–
boardExpressible todayToolbar (search, filters, New issue); the status groups are Section + Row; bulk selection = Row selection + a Toolbar selection bar–
issue-detailNeeds a new nativea markdown EDITOR with mentions, issue refs and image paste (Textarea is plain text); the property tray is Chip + Select; the activity is Row + Markdownyes
issue-commentsNeeds a new nativethe same editor for the composer; threaded comments are Row + Markdownyes
issue-createNeeds a new nativethe markdown editor; title + property Chips + FileUpload otherwiseyes
searchExpressible todaySearchList over Section + Row–
board-emptyExpressible todayEmptyState + the getting-started block (Section + Meter + Row)–
board-bulk-editExpressible todayToolbar as the floating selection bar (Chip count + Select ×3 + Button)–
issue-propertiesExpressible todayDrawer + Group + Row (chevron selector) + Select / DatePicker–
search-listExpressible todaySearchList (the catalog's own); the caret anchoring is the host's–
inboxExpressible todayList sectionBy + Section + Row + LiveDot (app extension)–
my-issuesExpressible todayTabs + List + Row–
draftsExpressible todayList + Row (title, meta) + EmptyState–
agentsNeeds an app extension componentthe per-login usage rows (three Meters with reset captions in one line) are an app component; Section + Row + IconDisc + LiveDot for the device rowsyes
actions-mobileExpressible todaySection + Row with a trailing Button–
chatNeeds an app extension componentthe launcher composer (issue chips, device + agent pickers, the + and / menus) is an app component over Composer; the greeting is Textyes
agent-homeNeeds an app extension componentthe same launcher composeryes
chat-issuesNeeds an app extension componentthe launcher composer with Chipsyes
steeringNeeds a new nativethe session feed: Trace + Markdown + the ask dock cards, but a streaming transcript with tool-call cards needs a feed NATIVE (windowed, sticky-bottom, live)yes
threadNeeds a new nativethe conversation feed (the same feed native); Tabs + Chip for the strip; the session card is Row surface cardyes
thread-repliesExpressible todayStack horizontal of Chips (pressable) with a dismiss Button–
chat-from-issueNeeds an app extension componentthe launcher composeryes
mention-pillNeeds an app extension componentText whose markdown runs carry an inline Badge (the round pill variant) for a resolved @member, in a chat bubble Box; needs inline chips inside Textyes
machine-settingsExpressible todayPage (title, back) + Group + Row + Switch + Select; FormActions for Update / Remove–
add-serverExpressible todayCard ×2 + CodeBlock + Button–
review-diffNeeds a new nativea DIFF viewer (hunks, syntax, expand context) and the file tree (Tree); CodeBlock is not a diffyes
action-pageNeeds a new nativethe markdown editor for the prompt body; Page + Group + Row + Select otherwiseyes
support-reporterNeeds a new nativethe markdown editor for the reply box (plain Textarea would do for a reporter); Markdown + Image + Row otherwiseyes
settings-generalExpressible todayPage + Group + Input + FormActions–
settings-membersExpressible todayPage + Section + Row (Avatar lead, role Select trailing) + ChipInput; FormActions for Send invites–
settings-labelsExpressible todaySection + Row with a Chip (dot colour) + Button; the colour picker is a Select of swatches–
settings-statusesExpressible todaySection ×6 + Row + Select; FieldGroup for the automation targets–
settings-storageExpressible todayTable (name, size, referenced, date, slot action) + Button–
settings-boardsExpressible todayPage + FieldGroup (icon, colour, name in one row) + Group + Select + FormActions–
settings-repositoriesExpressible todaySection + Row + Button; the GitHub connect is a host function–
settings-widgetNeeds an app extension componentthe live whole-panel widget preview is an app component; Form + Input + ChipInput + Select + Switch for the restyes
settings-accountExpressible todayPage + Group + Row (sign-in methods with trailing Buttons) + Select; FormActions–
settings-notificationsExpressible todayPage + Group + Row with two Switches trailing (a Toolbar-like control row) + TimePicker; FormActions–
settings-assistantExpressible todayS-3: Page + a header Row (the brand mark IconDisc, the name) + Groups of picker Rows (Select trailing: Runs on, Check in) and Switch Rows (the Voice block) + a TextField Row for the instructions; saves on change–
settings-assistant-meetingExpressible todayNEW-57: the same Page scrolled to its Meeting mode Group: Switch Rows (Meeting mode, Desktop audio) + a picker Row (Stop after, Select trailing)–
settings-api-keysExpressible todayPage + Section + Row (sessions with Log out) + Table (keys) + Dialog + FieldGroup (name, scope) + AlertDialog–
settings-teamExpressible todayGroup + Row chevron–
settings-rootExpressible todaySection (per server) + Row chevron–
settings-widget-editorNeeds an app extension componentTabs + Form, but the Appearance tab's live widget preview is an app componentyes
settings-agentsExpressible todaySection tinted + Row + Section (events) + Row–
settings-toolsExpressible todayPage + Group + Input + Select; FormActions–
settings-worktreesExpressible todaySection + Row + Button (Prune) + AlertDialog–
settings-boards-archivedExpressible todaySection + Row + Button; the pending-deletion Card–
settings-mcpExpressible todaySection + Row (Chip Default, trailing Button + Menu) + Dialog + Form; Tabs (Mine | Everyone) + Select for the access levels–
settings-mcp-activityExpressible todayList of Row (mono Text lead, caption, trailing Badge status)–
settings-issuesExpressible todayPage + Group + Segmented (estimate scale) + FieldGroup (the PR targets) + FormActions–
settings-importExpressible todayStepper + Card + Table (the mapping) + Progress + Button–
settings-team-agentNeeds a new nativePage + FieldGroup (icon, colour, name) + Group + Select + Row (schedule sentence) + Textarea + FormActions; the instructions want the markdown editor (native) to match the app (the macros have landed; the editor is what is left)yes
settings-promptsExpressible todaySection + Row + Button–
settings-promptNeeds a new nativethe markdown editor for the body; FieldGroup for icon + nameyes
settings-skillsExpressible todaySection + Row (Chip Default, trailing Button + Menu) + Button–
settings-skillNeeds a new nativethe markdown editor for the SKILL.md body; FieldGroup for name + description + Switchyes
filesNeeds a new nativeTree (the file tree with keyboard + drag) beside a read-only CodeBlock; Resizable splits themyes
source-controlNeeds a new nativethe diff viewer; the commit graph is an app extension; Row for the file listyes
terminalNeeds a new nativea TERMINAL (pty, cursor, selection); CodeBlock ansi is output onlyyes
getting-startedExpressible todaySection (title, Meter in the leading slot, Docs Link trailing) + Collapsible + Row–
appsExpressible todaySection (New app as its action) + Row per app (glyph, name, the version Chip, the … Menu); the built-in row the same with its Built-in Chip–
appNeeds a new nativethe editor session's conversation feed native; the preview is a nested Exponential UI surface, the versions Drawer is Row + Chip, the phones' switch Segmentedyes
app-settingsExpressible todaythe editor's Settings tab: FieldGroup (icon, colour, name in one row) + Textarea + the Tools group of Row + Switch + Export Button + the destructive Row–
app-versionsExpressible todaythe editor's Versions tab: one grouped List of Row (the v{n} Chip, the note, who · when) with Current Chip or Preview + Restore Buttons–
app-dataExpressible todaythe editor's Data tab: a toolbar (the kind Select, Add record Button) over a Table of records with a … Menu per row (Edit, Delete); phones one Row per record–
app-preview-phoneNeeds a new nativethe Preview tab's toolbar (Segmented Desktop | Phone, the template Select) over a nested Exponential UI surface in a 390 px frame; the frame is the host'syes
app-draftNeeds a new nativethe app editor on a draft (no version yet): the same native editor as app, its Chat tab with the empty previewyes
app-consentExpressible todayDialog: the operation's words as its title, the body Text, a CodeBlock of the arguments, Deny + Allow Buttons (the destructive Allow variant)–
app-exportExpressible todayDialog: the title Text, one Row with a Switch (Include records), the Export Button; the file hand-off is the host's–
app-importExpressible todayDialog: a multi-line TextField (monospace), a Choose file Button, a List of destructive Text rows (path: message), the Import Button–
settings-appsExpressible todaySection + Row + Button, the Agents settings band's twin–
session-rowExpressible todayRow (two densities) + depth + a trailing Icon; the session mark is IconDisc (app extension)–
session-xrefExpressible todayS-3: one compact Row (a muted Text kind label, Avatar (sm) or the session IconDisc, the name, a trailing chevron Icon); a tap pushes that session–
story-ringNeeds an app extension componentS-3: an Avatar inside a segmented ring, one arc per story tinted by state (waiting / unseen / seen): a StoryRing painter in the app extensionyes
story-viewerExpressible todayS-3: a Dialog (full screen on phones) of a Row of Progress segments, a header Row (Avatar, name, kind · age Text, close Button), the draft as a nested surface and a footer Row of choice Buttons–
story-tabExpressible todayList of Card tiles: a title Row (Text title, a muted kind · outcome · age Text), then a horizontal Stack of Image thumbnails each with a short caption Text–
thread-storiesExpressible todaythe Stack band + StackRail (Section tree) and one Changes Row over the pull request, then the story-tab tiles (Card + Image thumbnails + one line of Text each)–
pr-rowExpressible todayRow + Ring lead + depth (tree guides); the stack rail is Section tree–
pr-checksExpressible todayButton pill (Spinner / Icon lead) for the Merge state + a Menu submenu of Rows (status Icon, name, duration) whose press is openUrl; merging on red confirms through Dialog–
composer-menuExpressible todayMenu with submenus (searchable) over a Button–
composer-skills-menuExpressible todayMenu with submenus (searchable) over a Button, multi-pick rows–
composer-dialog-menuExpressible todaythe same Menu with submenus inside a Dialog; that a desktop dialog's menu needs a window of its own is the host's (the gpui renderer), not the catalog's–
composer-slash-menuExpressible todaySearchList in a Popover–
session-slash-menuExpressible todaySearchList in a Popover–
work-cardExpressible todayCard over a Row (icon, name, kind, elapsed) + Meter + Collapsible phases + Trace–
script-cardExpressible todayCollapsible + Trace + TraceItem + CodeBlock–
sealed-lockedExpressible todayBadge (the glass pill) + Icon (lock) + Text (italic muted preview)–
comment-call-cardExpressible todayCard (padded false) + Row (Avatar lead, meta) + Markdown lines 3 + Link–
traceExpressible todayTrace + TraceItem + CodeBlock (the catalog's own)–
ask-dockExpressible todayCard + Markdown + Radio / ChoiceTiles + Button; the stacked compact cards are Row surface card–
dock-cardExpressible todayDockCard (the catalog's own: the row over a bottom Drawer with its footer slot); the pile is Box + the stack geometry–
plan-cardExpressible todayCard + Markdown + Image + ChoiceTiles + Radio + Button; the Preview frame is a Box–
plan-pageExpressible todaySplitView + Outline + Section + Markdown + Mosaic + Carousel (counter, thumbnails) + Button–
code-blockExpressible todayCodeBlock (the catalog's own)–
feedback-statesExpressible todaySpinner + Button loading + Skeleton + Toast; the mark's stroke sweep is IconDisc (app extension)–
threadsExpressible todaySection ×3 + Row (identifier, title, meta word, trailing device Icon) + LiveDot–
chat-listExpressible todayBox header (Heading + two icon Buttons) + a horizontal Row of Avatar bubbles with a ring + Segmented toggles for the chips + Row (Avatar lead, title, meta time, preview subtitle, trailing Badge)–
chat-menusExpressible todayMenu as a context menu (the phone look is a bottom Drawer of the same rows) + Dialog for the delete confirm–
chat-selectionExpressible todaythe board-bulk-edit pattern on chat rows: Checkbox as the leading selection column + Toolbar as the floating bulk bar (Chip count + Buttons: Mark read, Archive, Stop, Delete)–
device-readinessExpressible todaySection per group (muted trailing tag) of Rows: state Icon, label, the detail as Text, one trailing Chip action (an import Chip before it, confirmed by a Dialog); computer_use a Switch row with its permission Rows indented under it–
voice-callExpressible todayS-3: Stack (centred) of Avatar (xl, an activity Ring around it, VoiceWave while speaking), Heading for the name, Text for the status or the timer, Text for the caption + a Group of round icon Buttons (size icon-lg: mute, speaker on phones, the destructive end); full screen on phones–
voice-call-overlayExpressible todayS-3: Row (fixed 320×56, glass) of a drag-handle Icon, Avatar (sm), a Stack of the name over the timer, VoiceWave (sm) and two round icon Buttons (mute, destructive end)–
call-controlsExpressible todayS-3: Group of round icon Buttons (size icon-lg; neutral, pressed, destructive; a Label under the ringing Cancel)–
chat-transcriptExpressible todayBox bubbles (Markdown, a meta time) + Chip for the divider pills; the inline preview IS a nested surface, its enter motion the renderer's–
phone-tab-barExpressible todaySegmented in its full-width bar variant (icon over label, Avatar for Team, a trailing caret icon on the Board tab) beside a two-Button Group capsule–
phone-board-menuExpressible todayMenu (check items with a leading icon) in a full-width bottom panel–
phone-page-headerExpressible todayAppBar with a large title and two icon Buttons in its trailing slot–
schedule-pickerExpressible todaySection of Rows with trailing Select values (Repeat, Every, Minute, Time, Days, Time zone) + a searchable Select–
sessionNeeds a new nativethe session feed native; AppBar + Button Stop + Composer otherwiseyes
agent-channelNeeds a new nativethe conversation feed native; Row + Markdown + Composer otherwiseyes