click a shot for 1:1 j / k moves · / filters · 1 / 2 / 3 / 4 switches section 217/217 captured · 98 n/a · 11 style · 81 general components · 38 special components · 63 views

Sign in & onboarding

Sign in

sign-in

The one pre-team surface every client shares: one Continue list with the OIDC providers the instance advertises, email and passkey.

Web1800×1200 · 15 KB
Sign in on Web
Web mobile832×1800 · 20 KB
Sign in on Web mobile
Desktop1800×1126 · 15 KB
Sign in on Desktop
iOS828×1800 · 16 KB
Sign in on iOS
Android810×1800 · 14 KB
Sign in on Android

Sign in & onboarding

Create / join team

onboarding

Create-or-join: the choice card every signup lands on before it has a team.

Web1800×1200 · 12 KB
Create / join team on Web
Web mobile832×1800 · 16 KB
Create / join team on Web mobile
Desktop1800×1126 · 11 KB
Create / join team on Desktop
iOS828×1800 · 23 KB
Create / join team on iOS
Android810×1800 · 24 KB
Create / join team on Android

Sign in & onboarding

Onboarding · Create a team

onboarding-create-team

Name the team you will own: the wizard’s create branch on web and desktop, the sheet behind the board switcher’s New team row on the phones.

Web1800×1200 · 13 KB
Onboarding · Create a team on Web
Web mobile832×1800 · 17 KB
Onboarding · Create a team on Web mobile
Desktop1800×1126 · 10 KB
Onboarding · Create a team on Desktop
iOS828×1800 · 30 KB
Onboarding · Create a team on iOS
Android810×1800 · 33 KB
Onboarding · Create a team on Android

Sign in & onboarding

Onboarding · Join a team

onboarding-join

The wizard’s other branch: paste the invite link a teammate sent you.

Web1800×1200 · 14 KB
Onboarding · Join a team on Web
Web mobile832×1800 · 19 KB
Onboarding · Join a team on Web mobile
Desktop1800×1126 · 12 KB
Onboarding · Join a team on Desktop
Not on iOS, Android
  • iOS The mobile wizard puts the Create and Join cards on ONE step (TeamSetupView / OnboardingScreen), so this branch is not a screen of its own there — see `onboarding`.
  • Android The mobile wizard puts the Create and Join cards on ONE step (TeamSetupView / OnboardingScreen), so this branch is not a screen of its own there — see `onboarding`.

Sign in & onboarding

Onboarding · Invite teammates

onboarding-invite

Step 3 of the first-run wizard, right after the first board: mint an invite link to share, or skip. The phones show the control only while the team has free seats.

Web1800×1200 · 16 KB
Onboarding · Invite teammates on Web
Web mobile832×1800 · 21 KB
Onboarding · Invite teammates on Web mobile
Desktop1800×1126 · 12 KB
Onboarding · Invite teammates on Desktop
iOS828×1800 · 20 KB
Onboarding · Invite teammates on iOS
Android810×1800 · 19 KB
Onboarding · Invite teammates on Android

Sign in & onboarding

Onboarding · Set up devices

onboarding-devices

The last wizard step, and the one step an invited teammate with no device gets after accepting: get the desktop app, copy the server install command and sign the agents in on the machines that show up. Skippable; the checklist keeps whatever was skipped.

Web1800×1200 · 35 KB
Onboarding · Set up devices on Web
Web mobile832×1800 · 47 KB
Onboarding · Set up devices on Web mobile
Desktop1800×1126 · 14 KB
Onboarding · Set up devices on Desktop
iOS828×1800 · 39 KB
Onboarding · Set up devices on iOS
Android810×1800 · 40 KB
Onboarding · Set up devices on Android

Sign in & onboarding

Accept an invite

invite-accept

The landing page a teammate opens from an invite link, with the team they were invited to and the accept button.

Web1800×1200 · 9 KB
Accept an invite on Web
Web mobile832×1800 · 10 KB
Accept an invite on Web mobile
Desktop1800×1126 · 69 KB
Accept an invite on Desktop
Not on iOS, Android
  • iOS InviteAcceptView accepts the invite the moment it appears (spinner → “Welcome!”): there is no landing card to photograph, and pressing on would burn the seeded token.
  • Android Android does render the landing card, but the styleguide lane is a side-by-side comparison — a shot that exists on one native platform only has nothing to sit next to, so this stays web + desktop.

Issues

Board

board

The issue list grouped by status: the app's home screen and the shot everything else is judged against.

Web1800×1200 · 80 KB
Board on Web
Web mobile832×1800 · 46 KB
Board on Web mobile
Desktop1800×1126 · 75 KB
Board on Desktop
iOS828×1800 · 63 KB
Board on iOS
Android810×1800 · 63 KB
Board on Android

Issues

Issue detail

issue-detail

One issue in full: the shared work header (title, Issue | Run face toggle, properties tray ending in the one coding action), markdown description and activity.

Web1800×1200 · 73 KB
Issue detail on Web
Web mobile832×1800 · 65 KB
Issue detail on Web mobile
Desktop1800×1126 · 67 KB
Issue detail on Desktop
iOS828×1800 · 65 KB
Issue detail on iOS
Android810×1800 · 64 KB
Issue detail on Android

Issues

Issue comments

issue-comments

The same issue scrolled to its activity feed: threaded comments, mentions and the composer.

Web1800×1200 · 68 KB
Issue comments on Web
Web mobile832×1800 · 65 KB
Issue comments on Web mobile
Desktop1800×1126 · 67 KB
Issue comments on Desktop
iOS828×1800 · 61 KB
Issue comments on iOS
Android810×1800 · 58 KB
Issue comments on Android

Issues

New issue

issue-create

The new-issue page: the issue detail in draft mode with title, property chips, description and files, autosaved as a draft until Create files it.

Web1800×1200 · 29 KB
New issue on Web
Web mobile832×1800 · 13 KB
New issue on Web mobile
Desktop1800×1126 · 25 KB
New issue on Desktop
iOS828×1800 · 29 KB
New issue on iOS
Android810×1800 · 29 KB
New issue on Android

Issues

Empty board

board-empty

A board with nothing on it yet: the empty state, with the getting-started cards inline underneath on every client.

Web1800×1200 · 57 KB
Empty board on Web
Web mobile832×1800 · 42 KB
Empty board on Web mobile
Desktop1800×1126 · 52 KB
Empty board on Desktop
iOS828×1800 · 59 KB
Empty board on iOS
Android810×1800 · 52 KB
Empty board on Android

Issues

Bulk edit

board-bulk-edit

Several issues selected at once: the action bar that re-statuses, re-assigns, re-labels or deletes the lot.

Web1800×1200 · 87 KB
Bulk edit on Web
Web mobile832×1800 · 46 KB
Bulk edit on Web mobile
Desktop1800×1126 · 80 KB
Bulk edit on Desktop
iOS828×1800 · 61 KB
Bulk edit on iOS
Android810×1800 · 62 KB
Bulk edit on Android

Issues

Board switcher

board-switcher

The phone’s way between boards: a bottom sheet listing every board in the team.

Web mobile832×1800 · 16 KB
Board switcher on Web mobile
iOS828×1800 · 36 KB
Board switcher on iOS
Android810×1800 · 39 KB
Board switcher on Android
Not on Web, Desktop
  • Web Desktop viewports list every board in the sidebar, so there is no switcher to open.
  • Desktop The rail lists the boards permanently; same reason as web.

Issues

Issue properties (mobile)

issue-properties

The phone’s properties sheet: status, priority, assignee, labels, due date and board, all from one pull-up.

Web mobile832×1800 · 18 KB
Issue properties (mobile) on Web mobile
iOS828×1800 · 40 KB
Issue properties (mobile) on iOS
Android810×1800 · 32 KB
Issue properties (mobile) on Android
Not on Web, Desktop
  • Web Wide layouts show the properties inline in the issue’s right-hand rail — see issue-detail.
  • Desktop Desktop shows the same properties inline in the issue header and rail.

My work

Inbox

inbox

The notification stream: assignments, mentions, comments and PR events, grouped and unread-first.

Web1800×1200 · 45 KB
Inbox on Web
Web mobile832×1800 · 36 KB
Inbox on Web mobile
Desktop1800×1126 · 38 KB
Inbox on Desktop
iOS828×1800 · 36 KB
Inbox on iOS
Android810×1800 · 34 KB
Inbox on Android

My work

My issues

my-issues

Everything assigned to me across every board in the team. A tab on the inbox route, not a route of its own.

Web1800×1200 · 40 KB
My issues on Web
Web mobile832×1800 · 25 KB
My issues on Web mobile
Desktop1800×1126 · 31 KB
My issues on Desktop
iOS828×1800 · 24 KB
My issues on iOS
Android810×1800 · 24 KB
My issues on Android

My work

Drafts

drafts

Issues that were started and not filed: leaving the new-issue page with anything in it keeps a draft, and a row reopens that page on it.

Web1800×1200 · 28 KB
Drafts on Web
Web mobile832×1800 · 18 KB
Drafts on Web mobile
Not on Desktop, iOS, Android
  • Desktop Not captured yet — the IDE reaches drafts from the sidebar entry and has no EXP_DEV_* drive for it.
  • iOS Not captured yet — the iOS drafts list has no UI test in either lane.
  • Android Not captured yet — the Android drafts list has no UI test in either lane.

Coding

Devices

agents

The machines that can run a coding session: the caller's desktops and headless servers, each listing the agent logins it holds — identity, health and its rate-limit numbers. A row starts nothing: its one control is a settings gear that opens Device settings.

Web1800×1200 · 39 KB
Devices on Web
Web mobile832×1800 · 35 KB
Devices on Web mobile
Desktop1800×1126 · 42 KB
Devices on Desktop
iOS828×1800 · 34 KB
Devices on iOS
Android810×1800 · 34 KB
Devices on Android

Coding

Actions

actions-mobile

The action catalog: one row per reusable agent run, each with its play button. Phones reach it through its own Actions tab and list every team's actions grouped by team (EXP-1186/1187); web md+ and desktop through the sidebar's Actions entry.

Web1800×1200 · 38 KB
Actions on Web
Web mobile832×1800 · 34 KB
Actions on Web mobile
Desktop1800×1126 · 33 KB
Actions on Desktop
iOS828×1800 · 31 KB
Actions on iOS
Android810×1800 · 31 KB
Actions on Android

Coding

Agent

chat

The Agent page: the composer — the ONE launcher since EXP-825 — here empty: a free prompt bound to no issue that needs no repository. EXP-923 moved the runs off the page: what is RUNNING is a section of the sidebar, and the history sits behind the page's own toggle.

Web1800×1200 · 34 KB
Agent on Web
Web mobile832×1800 · 39 KB
Agent on Web mobile
Desktop1800×1126 · 28 KB
Agent on Desktop
iOS828×1800 · 33 KB
Agent on iOS
Android810×1800 · 34 KB
Agent on Android

Coding

Agent · Issues

chat-issues

The composer with work attached: one chip per picked issue above the prompt, the machine and agent pickers under it. Two issues off the board, handed to one run.

Web1800×1200 · 34 KB
Agent · Issues on Web
Web mobile832×1800 · 42 KB
Agent · Issues on Web mobile
Desktop1800×1126 · 28 KB
Agent · Issues on Desktop
iOS828×1800 · 34 KB
Agent · Issues on iOS
Android810×1800 · 35 KB
Agent · Issues on Android

Coding

Agent · Action

chat-action

The composer running a saved action: the action chip above the prompt and the action's typed inputs under it — here the builtin Fix merge conflicts with an open PR already filled in.

Web1800×1200 · 30 KB
Agent · Action on Web
Web mobile832×1800 · 37 KB
Agent · Action on Web mobile
Desktop1800×1126 · 28 KB
Agent · Action on Desktop
iOS828×1800 · 34 KB
Agent · Action on iOS
Android810×1800 · 34 KB
Agent · Action on Android

Coding

Steering a session

steering

One coding session on its own page: it opens as the status row over the run's results thread with the agent's open question still in place, Show work expands the full scrubbed transcript, and the reply box answers it.

Web1800×1200 · 52 KB
Steering a session on Web
Web mobile832×1800 · 42 KB
Steering a session on Web mobile
Desktop1800×1126 · 43 KB
Steering a session on Desktop
iOS828×1800 · 39 KB
Steering a session on iOS
Android810×1800 · 37 KB
Steering a session on Android

Coding

Start coding from an issue (mobile)

chat-from-issue

The phone's way into the composer: the issue's Start coding button lands on the Agent page with that one issue already chipped. The store slide keeps its start-coding filename.

iOS828×1800 · 31 KB
Start coding from an issue (mobile) on iOS
Android810×1800 · 32 KB
Start coding from an issue (mobile) on Android
Not on Web, Web mobile, Desktop
  • Web The same composer chat-issues photographs, with one chip instead of two — the browser has no separate view.
  • Web mobile See web.
  • Desktop See web: the IDE's Start coding button opens the same chat-issues screen.

Coding

Device settings

machine-settings

One device up close, opened by the gear on its row: name, whether it is your default, what it shares with the team, its per-agent defaults, then Update and Remove device as a plain row. EXP-1020 took the worktrees out — a machine's worktrees are local, so the IDE's Settings → Worktrees owns them. EXP-862 renamed the dialog from Machine settings; the view id and the native shot names keep the old spelling so the store keeps its history.

Web1800×1200 · 22 KB
Device settings on Web
Web mobile832×1800 · 30 KB
Device settings on Web mobile
Desktop1800×1126 · 36 KB
Device settings on Desktop
iOS828×1800 · 26 KB
Device settings on iOS
Android810×1800 · 24 KB
Device settings on Android

Coding

Add a device

add-server

Both ways to put a machine on the account: download the desktop app, or run the one-liner that installs the CLI daemon on a server. The same device-setup block as the onboarding step.

Web1800×1200 · 29 KB
Add a device on Web
Desktop1800×1126 · 43 KB
Add a device on Desktop
Not on Web mobile, iOS, Android
  • Web mobile EXP-1169: the dialog holds the shared device-setup block and opens on phones too. The capture lands with the next screenshot refresh (a declared capture without a committed webp fails shots:check).
  • iOS EXP-1169: AddDeviceSheet (Devices tab pill, readiness fix) holds the same block as the wizard step. See web-mobile for the capture.
  • Android See ios.

Reviews

Reviews

reviews

Every open PR across the team, issue-linked ones grouped by board, each with a one-click squash merge.

Web1800×1200 · 43 KB
Reviews on Web
Web mobile832×1800 · 34 KB
Reviews on Web mobile
Desktop1800×1126 · 35 KB
Reviews on Desktop
iOS828×1800 · 33 KB
Reviews on iOS
Android810×1800 · 34 KB
Reviews on Android

Reviews

Review diff

review-diff

A PR's real diff on the issue's Changes face (EXP-1154: the review IS the issue's Work screen): the file TREE beside the file cards, every card open. APP-14 fetches a genuine patch from GitHub.

Web1800×1200 · 90 KB
Review diff on Web
Web mobile832×1800 · 74 KB
Review diff on Web mobile
Desktop1800×1126 · 87 KB
Review diff on Desktop
iOS828×1800 · 93 KB
Review diff on iOS
Android810×1800 · 77 KB
Review diff on Android

Reviews

Run changes

run-changes

The PR diff of an ISSUE-LESS run (a chat or action run's own pull request, EXP-1194): the same file cards as the issue's Changes face, fed by codingSessions.prFiles, opened from the Reviews "Agent runs" row. A teammate's run, so it reads read-only.

Web1800×1200 · 101 KB
Run changes on Web
Web mobile832×1800 · 71 KB
Run changes on Web mobile
Desktop1800×1126 · 100 KB
Run changes on Desktop
iOS828×1800 · 99 KB
Run changes on iOS
Android810×1800 · 78 KB
Run changes on Android

Action authoring

Action

action-page

One action as a page: its prompt (name, icon, repository, MCP servers, the markdown body the agent runs), its triggers and its runs. The MCP servers list offers only team MCPs (no sign-in, or a member's shared connection) and every run of the action uses it.

Web1800×1200 · 61 KB
Action on Web
Web mobile832×1800 · 46 KB
Action on Web mobile
Desktop1800×1126 · 48 KB
Action on Desktop
Not on iOS, Android
  • iOS The Prompt tab is not captured on its own; `action-triggers` and `action-runs` photograph the page.
  • Android The Prompt tab is not captured on its own; `action-triggers` and `action-runs` photograph the page.

Action authoring

Action triggers

action-triggers

The Triggers tab of an action on a phone: each schedule and event watcher with its device and its switch.

Web mobile832×1800 · 16 KB
Action triggers on Web mobile
iOS828×1800 · 17 KB
Action triggers on iOS
Android810×1800 · 15 KB
Action triggers on Android
Not on Web, Desktop
  • Web A desktop viewport has no tabs: Triggers is a section of `action-page`.
  • Desktop The desktop app has no tabs: Triggers is a section of `action-page`.

Action authoring

Action runs

action-runs

The Runs tab of an action on a phone: every run of it, the triggered ones marked with their trigger's glyph.

Web mobile832×1800 · 12 KB
Action runs on Web mobile
iOS828×1800 · 13 KB
Action runs on iOS
Android810×1800 · 12 KB
Action runs on Android
Not on Web, Desktop
  • Web A desktop viewport has no tabs: Runs is a section of `action-page`.
  • Desktop The desktop app has no tabs: Runs is a section of `action-page`.

Action authoring

Trigger editor

trigger-editor

Adding a trigger to an action: a schedule or an event, the device that runs it and the account, model and effort it runs with.

Web1800×1200 · 18 KB
Trigger editor on Web
Desktop1800×1126 · 46 KB
Trigger editor on Desktop
iOS828×1800 · 20 KB
Trigger editor on iOS
Android810×1800 · 17 KB
Trigger editor on Android
Not on Web mobile
  • Web mobile The same form as a bottom sheet over the Triggers tab; the paired native shots cover it.

Action authoring

New action

action-create

Authoring an action by describing it: the Agent page composer with the Create action builtin picked as its chip — describe the action in the prompt and the creator run writes the definition, no form (EXP-257, EXP-825).

Web1800×1200 · 35 KB
New action on Web
Desktop1800×1126 · 29 KB
New action on Desktop
iOS828×1800 · 34 KB
New action on iOS
Android810×1800 · 36 KB
New action on Android
Not on Web mobile
  • Web mobile The phone reaches the creator from the Actions page's Actions tab ("New action" pushes the same composer); the paired native shots cover it.

Action authoring

Suggestions

action-suggestions

The starter catalog: curated action seeds that prefill the creator run, some setting up a trigger with them.

Web1800×1200 · 74 KB
Suggestions on Web
Web mobile832×1800 · 87 KB
Suggestions on Web mobile
Desktop1800×1126 · 87 KB
Suggestions on Desktop
iOS828×1800 · 94 KB
Suggestions on iOS
Android810×1800 · 101 KB
Suggestions on Android

Support

Reporter's conversation

support-reporter

What the person who filed the report sees: the magic-link page over their widget issue, with the report, their pictures, the team's replies and a reply box, with no account and no team.

Web1800×1200 · 32 KB
Reporter's conversation on Web
Web mobile832×1800 · 44 KB
Reporter's conversation on Web mobile
Not on Desktop, iOS, Android
  • Desktop A browser page, not an app surface: the reporter has no account, so no client ever renders it.
  • iOS See desktop — the magic link opens in the reporter’s browser.
  • Android See desktop — the magic link opens in the reporter’s browser.

Settings

Settings · General

settings-general

Team name, slug and identity. Owner-only.

Web1800×1200 · 43 KB
Settings · General on Web
Web mobile832×1800 · 33 KB
Settings · General on Web mobile
Desktop1800×1126 · 32 KB
Settings · General on Desktop
Not on iOS, Android
  • iOS Folded into the one native Team settings screen: see settings-team.
  • Android Folded into the one native Team settings screen: see settings-team.

Settings

Settings · Members

settings-members

Who is on the team, their roles, and the pending invites. The one settings section every member can see.

Web1800×1200 · 60 KB
Settings · Members on Web
Web mobile832×1800 · 50 KB
Settings · Members on Web mobile
Desktop1800×1126 · 43 KB
Settings · Members on Desktop
Not on iOS, Android
  • iOS Folded into the one native Team settings screen: see settings-team.
  • Android Folded into the one native Team settings screen: see settings-team.

Settings

Settings · Labels

settings-labels

The team's label palette, member-managed.

Web1800×1200 · 34 KB
Settings · Labels on Web
Web mobile832×1800 · 23 KB
Settings · Labels on Web mobile
Desktop1800×1126 · 25 KB
Settings · Labels on Desktop
Not on iOS, Android
  • iOS Folded into the one native Team settings screen: see settings-team.
  • Android Folded into the one native Team settings screen: see settings-team.

Settings

Settings · Statuses

settings-statuses

Custom issue statuses in their six categories, plus the PR-open and PR-merge automation targets.

Web1800×1200 · 42 KB
Settings · Statuses on Web
Web mobile832×1800 · 32 KB
Settings · Statuses on Web mobile
Desktop1800×1126 · 32 KB
Settings · Statuses on Desktop
Not on iOS, Android
  • iOS Folded into the one native Team settings screen: see settings-team.
  • Android Folded into the one native Team settings screen: see settings-team.

Settings

Settings · Storage

settings-storage

The team file manager: every attachment, per-file delete, and the unreferenced-image sweep. Owner-only.

Web1800×1200 · 58 KB
Settings · Storage on Web
Web mobile832×1800 · 57 KB
Settings · Storage on Web mobile
Desktop1800×1126 · 43 KB
Settings · Storage on Desktop
Not on iOS, Android
  • iOS Folded into the one native Team settings screen: see settings-team.
  • Android Folded into the one native Team settings screen: see settings-team.

Settings

Settings · Board

settings-boards

One board up close: icon, colour and name in one row, then prefix, repository and default branch, with Archive and Move to trash below. Since EXP-862 the settings nav IS the board list on web as well as desktop, and the bare /settings/boards forwards to the first board. Owner-only.

Web1800×1200 · 37 KB
Settings · Board on Web
Web mobile832×1800 · 28 KB
Settings · Board on Web mobile
Not on Desktop, iOS, Android
  • Desktop The same pane (SettingsSection::Board(id), EXP-288), but the drive vocabulary addresses settings sections by name and a board pane is keyed by id, so there is nothing stable to drive to.
  • iOS Folded into the one native Team settings screen: see settings-team.
  • Android Folded into the one native Team settings screen: see settings-team.

Settings

Settings · Repositories

settings-repositories

The team's GitHub repo registry and the App connection behind it. Member-visible.

Web1800×1200 · 36 KB
Settings · Repositories on Web
Web mobile832×1800 · 30 KB
Settings · Repositories on Web mobile
Desktop1800×1126 · 29 KB
Settings · Repositories on Desktop
Not on iOS, Android
  • iOS Folded into the one native Team settings screen: see settings-team.
  • Android Folded into the one native Team settings screen: see settings-team.

Settings

Settings · Widget

settings-widget

The embeddable widget: key, domain allowlist, board, reporter contact, labels, theme, and the live whole-panel preview. Owner-only.

Web1800×1200 · 49 KB
Settings · Widget on Web
Web mobile832×1800 · 46 KB
Settings · Widget on Web mobile
Not on Desktop, iOS, Android
  • Desktop The IDE pane (SettingsSection::Widget, EXP-771) is a read-only list plus a Manage on the web button; configuration stays web-only, so the web shot is the reference.
  • iOS No native section: widget configuration is web-only.
  • Android No native section: widget configuration is web-only.

Settings

Settings · Account

settings-account

Personal identity, sign-in methods (a changeable primary email, Google/Apple/OIDC linking, passkeys) and timezone. Never gated.

Web1800×1200 · 53 KB
Settings · Account on Web
Web mobile832×1800 · 47 KB
Settings · Account on Web mobile
Desktop1800×1126 · 37 KB
Settings · Account on Desktop
iOS828×1800 · 30 KB
Settings · Account on iOS
Android810×1800 · 28 KB
Settings · Account on Android

Settings

Settings · Notifications

settings-notifications

Email and push preferences, including the digest hour. Never gated.

Web1800×1200 · 52 KB
Settings · Notifications on Web
Web mobile832×1800 · 45 KB
Settings · Notifications on Web mobile
Desktop1800×1126 · 47 KB
Settings · Notifications on Desktop
Not on iOS, Android
  • iOS Folded into the native settings root: see settings-root.
  • Android Folded into the native settings root: see settings-root.

Settings

Settings · Security

settings-api-keys

Login sessions (the devices signed in through the desktop app or CLI, each with a Log out) over the personal expu_ API keys for scripts and MCP clients: list, mint, revoke (EXP-1054). FEED-76: the mint dialog offers the consent screen's team/board scope picker; a scoped key is MCP-only and its row wears the pick as a caption. Passkeys sat here from EXP-862 until EXP-1126 moved them to Account under Sign-in methods; the section keeps the Security name.

Web1800×1200 · 38 KB
Settings · Security on Web
Web mobile832×1800 · 31 KB
Settings · Security on Web mobile
Desktop1800×1126 · 27 KB
Settings · Security on Desktop
Not on iOS, Android
  • iOS Folded into the native settings root: see settings-root.
  • Android Folded into the native settings root: see settings-root.

Settings

Settings · Team (mobile)

settings-team

The condensed native team settings screen: one list covering general, members, labels, statuses, boards and repositories.

iOS828×1800 · 41 KB
Settings · Team (mobile) on iOS
Android810×1800 · 38 KB
Settings · Team (mobile) on Android
Not on Web, Web mobile, Desktop
  • Web Web splits this into separate section routes: settings-general, settings-members, settings-labels, settings-statuses, settings-boards, settings-repositories.
  • Web mobile See web.
  • Desktop Desktop follows the web split, one nav item per section.

Settings

Settings · Root (mobile)

settings-root

The native settings root: the signed-in servers, the teams under them, and the way into everything else.

iOS828×1800 · 14 KB
Settings · Root (mobile) on iOS
Android810×1800 · 17 KB
Settings · Root (mobile) on Android
Not on Web, Web mobile, Desktop
  • Web Web has no settings root: the sidebar links straight into a section, and bare /settings redirects to the first one the viewer may see.
  • Web mobile See web.
  • Desktop Desktop follows the web split, one nav item per section.

Settings

Settings · Widget editor

settings-widget-editor

One widget’s configuration over three tabs (board and domains, reporter contact and fields, appearance), ending on the Appearance tab where the whole embeddable panel previews live.

Web1800×1200 · 41 KB
Settings · Widget editor on Web
Not on Web mobile, Desktop, iOS, Android
  • Web mobile Widget configuration is web-only, and the editor is a wide three-column dialog.
  • Desktop Widget configuration is web-only; the IDE's Widget pane only lists widgets.
  • iOS Widget configuration is web-only.
  • Android Widget configuration is web-only.

Settings

Settings · Agents

settings-agents

Per-agent CLI path, model and effort, over the doctor’s live probe of git and the three agents.

Desktop1800×1126 · 28 KB
Settings · Agents on Desktop
Not on Web, Web mobile, iOS, Android
  • Web Agent binaries are a property of the MACHINE, not the team: only the client that spawns them configures them.
  • Web mobile See web.
  • iOS See web.
  • Android See web.

Settings

Settings · Tools

settings-tools

Where the IDE keeps its clones: repos root, branch prefix and the shell it launches terminals with.

Desktop1800×1126 · 38 KB
Settings · Tools on Desktop
Not on Web, Web mobile, iOS, Android
  • Web Machine-local paths; nothing to configure from a browser.
  • Web mobile See web.
  • iOS See web.
  • Android See web.

Settings

Settings · Worktrees

settings-worktrees

Every worktree the machine is holding, with the prune that reclaims the finished ones.

Desktop1800×1126 · 33 KB
Settings · Worktrees on Desktop
Not on Web, Web mobile, iOS, Android
  • Web Worktrees live on the device; the web app only queues remove/prune commands to it.
  • Web mobile See web.
  • iOS See web.
  • Android See web.

Settings

Settings · Archived boards

settings-boards-archived

Boards put away rather than deleted, and the way back. EXP-862 gave web its own page under the settings nav's board list; the 48h pending-deletion card joins the archived list once something is in the trash.

Desktop1800×1126 · 24 KB
Settings · Archived boards on Desktop
Not on Web, Web mobile, iOS, Android
  • Web EXP-862 gave web its own page at /t/$teamSlug/settings/boards/archived; its capture joins the store with the EXP-865 screenshot refresh.
  • Web mobile See web.
  • iOS Folded into the one native Team settings screen: see settings-team.
  • Android Folded into the one native Team settings screen: see settings-team.

Settings

Settings · MCP servers

settings-mcp-servers

The team's MCP servers: one row per server with where it lives, how many members connected and shared, a Shared pill when the viewer shares theirs, and the viewer's one action (Connect, Set key, Connected with Share with team / Stop sharing, or No sign-in needed). Each member connects their own account once; the server holds the credential. Owners add from a catalog or a pasted URL, with the technical fields behind Advanced.

Web1800×1200 · 44 KB
Settings · MCP servers on Web
Web mobile832×1800 · 38 KB
Settings · MCP servers on Web mobile
Desktop1800×1126 · 37 KB
Settings · MCP servers on Desktop
Not on iOS, Android
  • iOS No native section: MCP servers are configured on the web and the desktop IDE (SettingsSection::McpServers).
  • Android No native section: MCP servers are configured on the web and the desktop IDE (SettingsSection::McpServers).

Settings

Settings · Issues

settings-issues

The team's estimate scale and the pull request automation targets; Labels and Statuses are its sub-pages (EXP-630).

Web1800×1200 · 44 KB
Settings · Issues on Web
Web mobile832×1800 · 39 KB
Settings · Issues on Web mobile
Desktop1800×1126 · 33 KB
Settings · Issues on Desktop
Not on iOS, Android
  • iOS Folded into the one native Team settings screen: see settings-team.
  • Android Folded into the one native Team settings screen: see settings-team.

Settings

Settings · Import

settings-import

Owner-only wizard that brings a Linear workspace into the team: connect, preview, map, dry-run, import (EXP-630).

Web1800×1200 · 34 KB
Settings · Import on Web
Web mobile832×1800 · 23 KB
Settings · Import on Web mobile
Not on Desktop, iOS, Android
  • Desktop Web-only: the desktop app has no Import section; the wizard runs in the browser.
  • iOS Folded into the one native Team settings screen: see settings-team.
  • Android Folded into the one native Team settings screen: see settings-team.

Desktop IDE

Files

files

The repository’s file tree beside a read-only viewer: the IDE half of the desktop app.

Desktop1800×1126 · 41 KB
Files on Desktop
Not on Web, Web mobile, iOS, Android
  • Web No client but the desktop app holds a clone, so no other client has a file tree.
  • Web mobile See web.
  • iOS See web.
  • Android See web.

Desktop IDE

Source control

source-control

The working tree: commit graph, diff, the trunk-sync status and the confirmed commit-and-push.

Desktop1800×1126 · 62 KB
Source control on Desktop
Not on Web, Web mobile, iOS, Android
  • Web Desktop-only: it operates on the machine’s clone.
  • Web mobile See web.
  • iOS See web.
  • Android See web.

Desktop IDE

Terminal

terminal

A plain shell terminal: fullscreen in the content area, one tab in the bottom session bar.

Desktop1800×1126 · 21 KB
Terminal on Desktop
Not on Web, Web mobile, iOS, Android
  • Web Coding sessions run on the desktop app and the CLI daemon only; every other client watches a scrubbed activity feed instead — see steering.
  • Web mobile See web.
  • iOS See web.
  • Android See web.

Getting started

Getting started

getting-started

The setup checklist: connect a repo, install the desktop app or CLI, wire MCP, drop in the widget.

Web1800×1200 · 44 KB
Getting started on Web
Desktop1800×1126 · 51 KB
Getting started on Desktop
Not on Web mobile, iOS, Android
  • Web mobile No sheet on phones: the cards render inline under an empty board / empty team.
  • iOS Inline under an empty board / empty team.
  • Android Inline under an empty board / empty team.

1 Style · Colour

Fills

tokens-fills

The four white-alpha fills, on the page gradient they are designed against. Section under row under card under active — never a fifth step.

fillSectionoklch(1 0 0 / 4%)
fillRowoklch(1 0 0 / 6%)
fillCardoklch(1 0 0 / 6%)
fillActiveoklch(1 0 0 / 11%)
Web--glass-fill-*packages/ui/src/styles.css
Desktoptheme::glass::FILL_*apps/desktop/crates/theme/src/tokens.generated.rs
iOSGlassTokensapps/ios/ExpUI/Sources/GlassTokens.swift
AndroidGlassTokensapps/android/app/src/main/java/com/exponential/app/ui/theme/Glass.ktaliases of DesignTokens.generated.kt

1 Style · Colour

Strokes

tokens-strokes

Five hairlines. Row separates, section and card enclose, strong floats, active marks a selection — pick by JOB, never by contrast.

strokeRowoklch(1 0 0 / 6%)
strokeSectionoklch(1 0 0 / 8%)
strokeCardoklch(1 0 0 / 10%)
strokeStrongoklch(1 0 0 / 12%)
strokeActiveoklch(1 0 0 / 16%)
Web--glass-stroke-*packages/ui/src/styles.css
Desktoptheme::glass::STROKE_*apps/desktop/crates/theme/src/tokens.generated.rs
iOSGlassTokensapps/ios/ExpUI/Sources/GlassTokens.swift
AndroidGlassTokensapps/android/app/src/main/java/com/exponential/app/ui/theme/Glass.ktaliases of DesignTokens.generated.kt

1 Style · Colour

Palette

tokens-palette

Every colour that is not a glass fill, in three groups. The neutral SURFACE palette is the shadcn theme the whole product sits on. The SEMANTIC accents are fixed sRGB, authored once and shared, because a status hue must not drift with the surface — plus the code trio, which tints inline code in the agent CHAT feeds and nowhere else. The DIFF pair is web's own look lifted verbatim so all four clients render one unified diff: a foreground per side over a 10% wash of the same hue.

Surface
backgroundoklch(0.145 0 0)
foregroundoklch(0.985 0 0)
cardoklch(0.205 0 0)
cardForegroundoklch(0.985 0 0)
popoveroklch(0.205 0 0)
popoverForegroundoklch(0.985 0 0)
primaryoklch(0.922 0 0)
primaryForegroundoklch(0.205 0 0)
secondaryoklch(0.269 0 0)
secondaryForegroundoklch(0.985 0 0)
mutedoklch(0.269 0 0)
mutedForegroundoklch(0.708 0 0)
accentoklch(0.269 0 0)
accentForegroundoklch(0.985 0 0)
destructiveoklch(0.704 0.191 22.216)
borderoklch(1 0 0 / 10%)
inputoklch(1 0 0 / 15%)
ringoklch(0.556 0 0)
sidebaroklch(0.205 0 0)
sidebarForegroundoklch(0.985 0 0)
sidebarAccentoklch(0.269 0 0)
sidebarBorderoklch(1 0 0 / 10%)
Semantic
neutral#A1A1AA
yellow#FACC15
green#22C55E
red#EF4444
orange#F97316
blue#3B82F6
codeText#8AB4F8
codeFill#8AB4F81F
codeStroke#8AB4F833
Diff
addFg#34D399
addBg#10B9811A
delFg#FB7185
delBg#F43F5E1A
hunkFgoklch(0.708 0 0)
hunkBgoklch(1 0 0 / 4%)
gutterFgoklch(0.708 0 0 / 50%)
Web--color-* / --diff-*packages/ui/src/styles.css
Desktoptheme::{PALETTE consts} / theme::diffapps/desktop/crates/theme/src/tokens.generated.rsthe semantic accents are top-level consts (YELLOW, GREEN, …), diff its own module
iOSDesignTokens.Palette / .Semantic / .Diffapps/ios/ExpUI/Sources/DesignTokens.generated.swift
AndroidDesignTokens.Palette / .Semantic / .Diffapps/android/app/src/main/java/com/exponential/app/ui/theme/DesignTokens.generated.kt

1 Style · Shape & size

Radius ladder

tokens-radius

Six steps. Row 10, group and field 12, card 16, sheet 24 — anything else is a mistake, and capsules use 9999 rather than a step. MD is also the BUTTON corner (EXP-1176: every text button, every size, every client) and the PICKER corner (EXP-771): an icon or colour picker trigger and every cell of the glyph grid take it, which is what keeps a picker from reading as a circular action button. 9999 belongs to the pill, the tabs, the badge and the phone bars.

sm · 8
md · 10
lg · 12
xl · 16
xl2 · 20
xl3 · 24
Web--radiuspackages/ui/src/styles.css
Desktoptheme::radius::*apps/desktop/crates/theme/src/tokens.generated.rs
iOSGlassTokensapps/ios/ExpUI/Sources/GlassTokens.swift
AndroidGlassTokensapps/android/app/src/main/java/com/exponential/app/ui/theme/Glass.ktaliases of DesignTokens.generated.kt

1 Style · Shape & size

Control heights

tokens-size

Three control heights plus the field and the list row. A control that is none of these is a control nobody agreed to.

controlLg36
controlMd32
controlSm24
inputHeight36
rowHeight32
WebbuttonVariants size-9 / Pill size md|smpackages/ui/src/button.tsx32 and 24 are the pill's md and sm, in packages/ui/src/pill.tsx
Desktoptheme::size::*apps/desktop/crates/theme/src/tokens.generated.rs
iOSGlassTokensapps/ios/ExpUI/Sources/GlassTokens.swift
AndroidGlassTokensapps/android/app/src/main/java/com/exponential/app/ui/theme/Glass.ktaliases of DesignTokens.generated.kt

1 Style · Type

Type

tokens-type

One family, Inter, and the sizes the agent TRANSCRIPT is set in (EXP-787) — the only type scale the four clients share, because a run has to read the same on a phone and on a 32-inch monitor. Body 14/22 is prose and a sent message; tool 12/18 is a tool row and every other caption in the feed. 16 is the root the rem ladder is measured from. The specimens below are set in this page's own stack: Inter is not loaded here, and faking it with a fallback would make the page lie about the one thing it documents.

fontFamilyThe quick brown fox jumps over the lazy dogInter
baseSizeThe rem ladder is measured from here16px
transcript.bodyRebased onto origin/master and force-pushed; the merge queue is green again.14/22
transcript.toolRead packages/ui/src/file-diff-tree.tsx12/18
Web--font-sans / --transcript-*packages/ui/src/styles.cssthe gap ladder derives in lib/agent-feed.ts; design-tokens.test.ts guards the parity
Desktoptheme::transcriptapps/desktop/crates/theme/src/tokens.generated.rs
iOSDesignTokens.Transcriptapps/ios/ExpUI/Sources/DesignTokens.generated.swift
AndroidDesignTokens.Transcriptapps/android/app/src/main/java/com/exponential/app/ui/theme/DesignTokens.generated.kt

1 Style · Motion

Motion

tokens-motion

Three durations against three easings — hover a line to run it. Fast for micro-feedback, standard for most transitions, slow for whole surfaces.

fast 120 · standard
fast 120 · decelerate
fast 120 · accelerate
standard 180 · standard
standard 180 · decelerate
standard 180 · accelerate
slow 280 · standard
slow 280 · decelerate
slow 280 · accelerate
Web--motion-*packages/ui/src/styles.css
Desktoptheme::motion::duration / easeapps/desktop/crates/theme/src/tokens.generated.rs
iOSGlassTokensapps/ios/ExpUI/Sources/GlassTokens.swift
AndroidGlassTokensapps/android/app/src/main/java/com/exponential/app/ui/theme/Glass.ktaliases of DesignTokens.generated.kt

1 Style · Icons

Icon registry

tokens-icons

Every CONCEPT the product can name, rendered from the registry itself. One icon set, Lucide, byte-identical on four clients, generated from packages/icons/icons.json into a committed output per platform — so an icon changes in one JSON and nowhere else. A multi-client surface names the concept (conceptIcon("nav-search")), never a raw glyph import, which is what lets the same screen carry the same mark on a phone, a browser and the IDE. This entry is an ISLAND of the real registry: a lookalike table would be the one place the icons could drift.

WebconceptIcon / ICON_COMPONENTSpackages/ui/src/icons.generated.tsthe concept → glyph map is SEMANTIC_ICONS in packages/icons/src/generated.ts
Desktopicons::registryapps/desktop/crates/ui/src/icons.rspub mod registry include!s icons.generated.rs; concepts are consts (registry::NAV_SEARCH)
iOSAppIconsapps/ios/ExpUI/Sources/AppIcons.generated.swiftasset names for the bundled imagesets, rendered through AppIcon — never Image(systemName:)
AndroidExpIconsapps/android/app/src/main/java/com/exponential/app/ui/icons/ExpIcons.generated.ktthe geometry itself: one lazy ImageVector per glyph, with the concepts as getters

1 Style · Icons

Brand marks

brand-marks

The agents' own marks, deliberately OUTSIDE the Lucide registry: a brand mark is not a glyph we may recolour or swap, so it never resolves through `conceptIcon`. Claude keeps its real path in its own orange (`CLAUDE_FILL`) on all four clients; Codex is the CLI's own mark; the OpenAI and Cursor marks are simplified silhouettes for the MCP setup tabs, which only need recognisability beside a name. `AgentBrandMark` is the resolver every run surface uses — and an agent this build ships no mark for falls back to the neutral `settings-agents` concept rather than wearing claude's, the same rule on all four.

WebClaudeIcon / CodexIcon / AgentBrandMarkpackages/ui/src/brand-icons.tsxthe resolver is agent-brand-mark.tsx; the picker's AgentMark shares these paths
Desktopcoding_selects::agent_markapps/desktop/crates/ui/src/coding_selects.rsExpIcon::Claude / ExpIcon::Codex off the bundled brand SVGs (assets/icons/claude.svg, codex.svg)
iOSAgentBrandMark.image(_:)apps/ios/Exponential/UI/Session/AgentBrandMark.swiftresolves Assets.xcassets/agent-<id>; an id outside contract.codingAgent gets the neutral glyph
AndroidagentIconPainter / agentIconTintapps/android/app/src/main/java/com/exponential/app/ui/components/SheetOptionRows.ktdrawable/ic_agent_claude.xml keeps its own orange (tint Unspecified); every other mark takes content colour

1 Style · Icons

Run state mark

run-state-mark

EXP-1184: what a LIVE run is doing, one rule ×4 (contract fixture `session-display.json`, first match wins): it waits on you → the AMBER badge; the agent works → Claude's own "writing" spark, eight hand-drawn frames at 90 ms with hard cuts (vendored once as `packages/icons/agent/claude-writing.svg`, generated into every client; an agent without working art pulses its brand mark instead; reduced motion holds the static mark); idle with its pull request open → the GREEN badge (in review); idle with no open pull request, or merged → the BLUE badge (done). A follow-up turn on a run in review reads working again; the session row and the issue status never change for it. The amber needs-input badge beats every other state (the red needs-you badge is gone), a paused run wears the bare mark. EXP-1208: every session LIST row ×4 leads with this mark too (running and ended rows, the action Runs list; no more state dots), and a FINISHED run's row wears the ENDED mark: the brand mark at half opacity with no badge. Where a surface still draws a dot (tab chips), the dot takes the state's tone and gives way to the same working mark.

WebAgentRunMark / AgentWorkingMark / ClaudeSpinnerpackages/ui/src/agent-brand-mark.tsxthe derivation is apps/web/src/lib/coding-session-display.ts; the spark steps a CSS steps(8) sprite (claude-spinner.tsx)
Desktopcoding_selects::run_leadapps/desktop/crates/ui/src/coding_selects.rsassets/icons/claude-writing-{0..7}.svg; the derivation is queries::coding_session_display
iOSAgentRunMark / ClaudeSparkSpinnerapps/ios/Exponential/UI/Session/AgentRunMark.swiftAssets.xcassets/agent-claude-writing-{0..7}; the derivation is ExpCore CodingSessionDisplay.swift
AndroidAgentRunMark / ClaudeSparkSpinnerapps/android/app/src/main/java/com/exponential/app/ui/components/AgentRunMark.ktdrawable/ic_agent_claude_writing_{0..7}.xml; the derivation is domain/CodingSessionDisplay.kt

2 General components · Inputs & pickers

Segmented control

segmented

The standalone capsule: 36 tall, padding 3, the section fill under a section stroke. Segments share the embedded row's geometry — EXP-941 made that second form a prop rather than a second component, so the settings strips and the free-floating ones are one control. Eight strips wired their own Tabs + TabsList + N triggers by hand before, and drifted in padding and in whether a segment carried a glyph; the class recipe still lives in tabs.tsx, which other surfaces read directly.

WebSegmentedControlpackages/ui/src/segmented-control.tsxthe SEGMENTED_* class constants stay in tabs.tsx byte-identical; this renders the strip from an option array
Desktopcontrols::segmentedapps/desktop/crates/ui/src/controls.rs
iOSGlassSegmentedControlapps/ios/ExpUI/Sources/GlassSegmentedControl.swift
AndroidGlassSegmentedControlapps/android/app/src/main/java/com/exponential/app/ui/components/GlassSegmentedControl.kt

2 General components · Inputs & pickers

Work face tabs

work-face-tabs

EXP-1152: the ONE strip every Work screen wears (the md+ work header, the phone's header band, the desktop IDE, iOS, Android): Issue · Run/Runs · +N −M · Results in that fixed order, the segmented capsule above. The Changes segment wears the diff's counts once its files are known and the word Changes until then (the desktop FaceToggle::diff rule); the Runs segment carries a caret to the run menu with several runs; an unavailable face is HIDDEN, never disabled, and the strip is absent under two faces. On phones the body under it is a PAGER: iOS FacePager, Android TabPager (HorizontalPager), and on the web useFaceSwipe moves the face's body with the finger and slides the next one in (the header band and the bar never move). EXP-1190: EVERY top tab strip on a phone pages with the same pager (My Work Inbox · My Issues · Drafts, Actions · Suggestions, the action page's Prompt · Triggers · Runs); a tap pages too. EXP-1162 (contract `detail-chrome.json` face marks): the tabs carry the STATE, the header title never does. The Run tab wears the run's mark while it is live (the sidebar Running row's `AgentRunMark`, leading the label: Claude's working spark while the agent works, else the brand mark with its run-state badge), never a dot; an open pull request puts a 6px dot on Results, or on Changes when there is no Results face. EXP-1154: on phones the strip sits centred, nothing beside it (Merge rides the floating bar), and an issue's Changes segment is the review of its pull request (the Reviews detail screen is gone).

WebWorkFaceStrippackages/ui/src/work-face-strip.tsxWorkFaceToggle + MobileFaceTabs in apps/web plug the run menu and the phone pager
Desktopwork_header::FaceToggleapps/desktop/crates/ui/src/work_header.rs
iOSWorkFaceTabsapps/ios/Exponential/UI/Work/WorkFaceTabs.swift
AndroidWorkFaceTabsapps/android/app/src/main/java/com/exponential/app/ui/work/FaceTabs.kt

2 General components · Inputs & pickers

Icon picker

icon-picker

The one surface that picks a glyph, and the one exception to the circle (EXP-771): a circle is the primary ACTION, ROUNDED SQUARE is a picker. The trigger is a square at the radius ladder's MD step over card fill, sized to the field it sits beside (web h-9, desktop and the natives the 32px control rung) — a card hairline once something is picked, a DASHED one under the placeholder glyph while it is empty — and the cells of the grid it opens wear that same corner, the picked one taking the active fill under the active stroke. It offers one of TWO sets from the one registry (EXP-924): the 96 board and action glyphs, or the six device glyphs (monitor, server, laptop and the Apple, Windows and Linux marks) beside a device's name, where the short set hugs its cells. EXP-862 gave the colour picker the SAME trigger, so the board form reads as one control repeated; the swatches inside it are the counter-example: a colour has no shape to read, so those stay circles.

WebIconPickerpackages/ui/src/icon-picker.tsxthe grid is icon-swatch-grid.tsx, rendered inside the trigger's popover
Desktopboard_form::icon_pickerapps/desktop/crates/ui/src/board_form.rsicon_swatch_grid is the grid in the same file
iOSIconPickerapps/ios/ExpUI/Sources/IconPicker.swiftthe grid is ExpUI/Sources/IconSwatchGrid.swift
AndroidIconPickerapps/android/app/src/main/java/com/exponential/app/ui/components/IconPicker.ktthe grid is ui/components/IconSwatchGrid.kt

2 General components · Inputs & pickers

Text field

text-field

36 tall, padding 0/12, radius 12, card fill under a card stroke; focus swaps the stroke to active — no ring. Placeholder at 50%.

WebInputpackages/ui/src/input.tsx
Desktopcontrols::glass_inputapps/desktop/crates/ui/src/controls.rsfocus swaps the stroke to strokeActive, no ring (EXP-720); the corner is radius.lg (EXP-963)
iOSGlassTextFieldapps/ios/ExpUI/Sources/GlassControls.swift
AndroidGlassTextFieldapps/android/app/src/main/java/com/exponential/app/ui/components/GlassTextField.kt

2 General components · Inputs & pickers

Text area

textarea

The field's own recipe, grown: radius 12, card fill under a card stroke, focus swaps the stroke to active — no ring. Padding 8/12, three rows tall, and it GROWS with content; the drag handle is off everywhere. Inside a group it goes borderless, because the row is already the chrome.

WebTextareapackages/ui/src/textarea.tsx
Desktopcontrols::web_textareaapps/desktop/crates/ui/src/controls.rs
iOSGlassTextField(lines:)apps/ios/ExpUI/Sources/GlassControls.swift
AndroidGlassTextField(minLines/maxLines)apps/android/app/src/main/java/com/exponential/app/ui/components/GlassTextField.kt

2 General components · Inputs & pickers

Checkbox

checkbox

A 16px rounded square that holds a TABLE's selection — the bulk-select column of an issue list and nothing else. It is deliberately NOT the multi-select affordance in a picker: every option row on all four clients marks itself with the leading circle pair (ui-selected / ui-unselected), so a checkbox inside a picker would make web the only client drawing selection twice. Checked takes the primary fill under the primary foreground; indeterminate is the same box with a minus.

WebCheckboxpackages/ui/src/checkbox.tsx
Desktopcontrols::checkboxapps/desktop/crates/ui/src/controls.rsglass box (row fill, strong stroke, radius SM), primary when checked, ui-check / ui-minus; bulk-select + checklist rows
iOSn/ano checkbox exists: a multi-select row draws the ui-selected / ui-unselected circle pair
Androidn/asame as iOS — the sheet's option rows carry the circle glyph pair, never a box

2 General components · Inputs & pickers

Switch

switch

The 36×20 capsule that flips a setting the moment it is pressed — there is no Save beside one. Off is the active fill under the foreground knob, on the primary fill under the primary-foreground knob, and the travel is one fast duration. It almost always rides a toggle ROW, which owns the label and the description; this is the bare control.

WebSwitchpackages/ui/src/switch.tsx
Desktopcontrols::web_switchapps/desktop/crates/ui/src/controls.rsa lint (only_controls_constructs_switches) refuses Switch::new anywhere else
iOSGlassToggleStyleapps/ios/ExpUI/Sources/GlassControls.swiftapplied app-wide as .toggleStyle(.glass), so call sites keep the stock Toggle
AndroidglassSwitchColors()apps/android/app/src/main/java/com/exponential/app/ui/components/SheetOptionRows.ktthe tokens for the stock M3 Switch; there is no GlassSwitch composable

2 General components · Inputs & pickers

Select

select

The closed single-select: a field-height trigger of card fill under a card hairline, the value left and a chevron right, focus swapping the stroke to active. Only the PLACEHOLDER arm can be photographed — SelectValue resolves against items that live inside the portalled list, so a valued trigger renders empty until the browser opens it (see @exp/ui's island limits). On the natives there is no free-standing select at all: the closed trigger is always a picker ROW opening a sheet.

WebSelect / SelectTriggerpackages/ui/src/select.tsx
Desktopsurface::glass_picker_selectapps/desktop/crates/ui/src/surface.rs
iOSn/ano free-standing select: the closed single-select is a GlassPickerRow opening a sheet (see picker row)
Androidn/asame as iOS — PickerRow (SheetOptionRows.kt) is the closed arm, and the sheet is the list

2 General components · Inputs & pickers

Colour picker

color-picker

EXP-862 made this the icon picker's TWIN, so the board form's two triggers read as one control repeated: the same rounded square at the radius ladder's MD step, a card hairline once something is picked and a DASHED one while it is empty, opening the same 8-column grid. The swatches are the counter-example to the shape rule — a colour has no shape to read, so a cell stays a circle, and the picked one wears a ring instead of a fill.

WebColorPickerpackages/ui/src/color-picker.tsxthe grid is color-swatch-grid.tsx, rendered inside the trigger's popover
Desktopboard_form::color_pickerapps/desktop/crates/ui/src/board_form.rscolor_swatch_grid is the grid in the same file
iOSColorSwatchPickerapps/ios/ExpUI/Sources/ColorSwatchGrid.swiftthe grid is ColorSwatchGrid in the same file
AndroidColorPickerapps/android/app/src/main/java/com/exponential/app/ui/components/ColorPicker.kt

2 General components · Inputs & pickers

Field label

label

The 14px medium line that names a field, tied to it by htmlFor so the label is part of the hit box, and dimmed with the field when it is disabled. It exists on the WEB only: every native lays a field out as a ROW that already carries its name on the left, so a label above the control there would say the same thing twice.

WebLabelpackages/ui/src/label.tsx
Desktopn/aa field's name is the glass row's own leading text — see input row
iOSn/asame — the name is the row title inside GlassTextField / GlassPickerRow
Androidn/asame — TextFieldRow / PickerRow carry the label themselves

2 General components · Inputs & pickers

Combobox

combobox

The ONE searchable picker. Its shell — MobilePopover over Command — was copy-pasted about twelve times, and every copy re-decided four things a reader can see: what a picked row LOOKS like, what value= carries, how wide the popover is, and what "nothing picked" is called. Selection is now fixed and matches both natives: SINGLE select marks the picked row with a trailing ui-check, MULTI marks EVERY row with the leading ui-selected / ui-unselected circle pair (iOS AgentIssuePickerSheet.swift, Android AgentIssuePickerSheet.kt) — never a checkbox, which would make web the odd client out. value is the IDENTITY and keywords the search text, so two boards may share a name; noneLabel renders a row that reports null, which retires six sentinel strings. Single closes on pick, a multi stays open because a batch is several picks. EXP-957 added the third arm, ComboboxMenuItems: the same rows as items INSIDE a Radix context or dropdown menu, for the issue row's right-click submenus and the bulk bar, which retires the menu's own radio dot and checkbox tick; and a bulk edit over rows that disagree draws ui-indeterminate (circle-minus) on a multi row, or marks nothing at all on a single. EXP-958 folded the last two closed single-selects onto it — the status and priority menu, whose desktop arm marked no row at all, and the settings picker row, which was a Select on desktop and a hand-rolled sheet on the phone — as searchable={false} pickers with two more triggers: row (the glass form ladder's picker row, label leading, value trailing) and inline (one word of the muted sentence under the composer, which collapses to plain text with a single option). The demo shows the four triggers beside the bare ComboboxList, since a closed portal renders nothing — and a menu arm cannot render outside its menu at all. EXP-1021 added selectionStyle: the circle pair above is the glyph arm, still what every Combobox call site draws, while the Picker primitive built on these surfaces passes highlight and marks a multi pick by the row's own wash. The four triggers moved to picker/picker-trigger.tsx so both arms draw ONE set.

WebCombobox / ComboboxList / ComboboxMenuItemspackages/ui/src/combobox.tsxPickerOption is the row shape; ComboboxList the body without the popover; ComboboxMenuItems the rows inside a Radix menu
Desktoppicker::Pickerapps/desktop/crates/ui/src/picker/mod.rsEXP-1021 retired searchable_picker; the primitive owns its query + cursor, so a host holds no picker entities
iOSGlassPickerSheetapps/ios/ExpUI/Sources/GlassSheet.swiftEXP-1021 built the generic picker (GlassPicker, its own entry); this keeps the picks outside the ten typed subjects
AndroidGlassSheetRowapps/android/app/src/main/java/com/exponential/app/ui/components/GlassSheet.ktEXP-1021 built the generic picker (Picker, its own entry); this row is what the sheets outside the ten assemble

2 General components · Inputs & pickers

Search field

search-field

The ONE "filter this list" field: the text field with the search glyph INSIDE it and a ghost clear that appears only once there is something to clear — and puts the caret back in the field, so typing continues. Eight of them existed at five heights, most a bare Input re-dressed by hand and none with either affordance, while both natives had drawn exactly this for years. Two rungs: md is the stock 36 field, sm the 28 one dense columns use — the Changes file tree's filter, a sidebar filter. It is an Input, not a new box: every chrome decision still comes from there.

WebSearchFieldpackages/ui/src/search-field.tsx
Desktopcontrols::search_fieldapps/desktop/crates/ui/src/controls.rsEXP-963: SearchFieldSize::Md 36 / Sm 28; the diff pane filter, every picker query and the search dialog draw it
iOSGlassSheetSearchFieldapps/ios/ExpUI/Sources/GlassControls.swift
AndroidGlassSheetSearchFieldapps/android/app/src/main/java/com/exponential/app/ui/components/GlassSheet.kt

2 General components · Inputs & pickers

Date picker

date-picker

The ONE date picker. Popover + Calendar was inlined three times — the properties panel, the editor chips, the mobile tray — and each copy converted between a Date and the wire's YYYY-MM-DD its own way, two of them through new Date(value), which the spec parses as UTC and which therefore shows the PREVIOUS day west of Greenwich. This one speaks the wire format on both sides and converts in exactly one place. A due date is a DATE, never an instant (REV2-49), so there is no time arm; the trigger is a Pill showing the short form, and Clear is a row under the grid rather than a second control beside it.

WebDatePickerpackages/ui/src/date-picker.tsxparseDateValue / formatDateLabel are the only place the wire date becomes a Date
Desktoppickers::due_date_popoverapps/desktop/crates/ui/src/pickers.rs
iOSDueDateSheetapps/ios/Exponential/UI/Issue/Sheets/DueDateSheet.swiftCreateIssueView keeps a second unfoldable form, UI/Issue/DueDatePicker.swift
AndroidDueDateSheetapps/android/app/src/main/java/com/exponential/app/ui/issue/DueDateSheet.ktthe grid itself is IssueDatePickerDialog.kt

2 General components · Inputs & pickers

Typeahead menu

typeahead

The menu that follows what someone is TYPING — @ mentions, # issue refs, : emoji, / commands — as opposed to the combobox, which owns its own field. Three copies existed, each re-implementing the same active index, the same wrap and the same above/below flip, and only one of them told its host whether it had handled the key: the other two signalled it by NOT calling the host's handler, which is how a menu ends up swallowing a send shortcut. One hook owns the keys now: arrows move and wrap, a plain Enter or Tab accepts, Enter with Cmd or Ctrl is the composer's send and passes straight through untouched, Escape dismisses, and with no items nothing is handled at all. The menu has two arms: absolute under a textarea, or anchored to a caret rect in the editor, where it portals to the body at fixed coordinates and flips above the caret when the room below runs out.

WebuseTypeahead / TypeaheadMenu / TypeaheadRowpackages/ui/src/typeahead.tsxhandleKeyDown returns true when the menu ate the key; the editor's caret menu is the anchored arm (EXP-959)
Desktopcontrols::typeahead_menu / typeahead_rowapps/desktop/crates/ui/src/controls.rsthe row stays completion_row_content; the anchored arm flips above the caret when room runs out, the slash menu inline
iOSEditorAutocompleteMenuapps/ios/Exponential/UI/Markdown/EditorAutocompleteMenu.swiftthe slash menu is separate: UI/Session/SlashCommandMenu.swift
AndroidAutocompleteMenuapps/android/app/src/main/java/com/exponential/app/ui/markdown/AutocompleteMenu.ktthe rows are AutocompleteRows in the same file

2 General components · Inputs & pickers

Password input

password-input

The auth pages' masked field: the stock Input with a ghost eye hung inside its right gutter, which flips the type between `password` and `text`. It is the one field a typo cannot be proof-read in, so the reveal is not optional chrome. The eye pair stays a RAW lucide import on purpose — no native client draws it and the shared registry has no eye-off concept to name. EXP-961 moved it into @exp/ui with the rest of the auth surface.

WebPasswordInputpackages/ui/src/password-input.tsx
Desktoplogin::render_email_stepapps/desktop/crates/ui/src/login.rsthe only other client with the reveal: it wires gpui-component's mask_toggle onto the shared glass_input
iOSGlassTextField(isSecure: true)apps/ios/ExpUI/Sources/GlassControls.swifta bare SecureField arm — no reveal, so a mistyped password can only be fixed by clearing the field
AndroidGlassTextField(visualTransformation = …)apps/android/app/src/main/java/com/exponential/app/ui/components/GlassTextField.ktLoginScreen passes PasswordVisualTransformation() inline and draws no trailing eye

2 General components · Inputs & pickers

Account picker

account-picker

EXP-872: ONE account picker per platform, replacing the agent picker + the account picker on every launch surface (the composer's options line, the automation editor's Account row, the workflow runner). The list is every signed-in login the machine reports across both agents, flattened (`flattenAccounts`): the row and the chip read as the agent's brand mark + the login's EMAIL — never a profile name, never the word "default"; the last used login is simply first, and a dead credential rides a muted hint. Picking a login implies its agent. One login collapses the inline word to plain text. EXP-992: on a pointer platform hovering a row shows a very small preview right of it — three 4px bars labelled 5h / week / <model> off the login's limits; on touch the same bars sit inline under the email in the sheet row. The menu is a portal, so the specimen is the closed trigger at both variants beside the bars block.

WebAccountPicker / AccountLimitBarspackages/ui/src/account-picker.tsxvariant inline = the composer word, row = the glass form ladder's picker row
Desktopcoding_selects::account_pickerapps/desktop/crates/ui/src/coding_selects.rsthe hover preview is an anchored overlay right of the menu row
iOSAccountPickerMenuapps/ios/ExpUI/Sources/AccountPicker.swifttouch: the three bars sit inline in the menu row
AndroidAccountPickerPillapps/android/app/src/main/java/com/exponential/app/ui/components/AccountPickerPill.kttouch: the three bars sit inline in the menu row

2 General components · Inputs & pickers

Agent picker

agent-picker

EXP-862: ONE agent picker per platform, so the trigger can never drift into a per-surface copy again. EXP-872 took it OUT of every launch surface — the composer, the device settings default row and the workflow runner pick an ACCOUNT now (the entry above), which implies the agent — so what is left picks an agent where there is no login yet: the Add-account sheet, and the per-agent defaults strip (`AgentPickerTabs`, the same picker as a segmented STRIP). The trigger is ICON-ONLY: the brand mark plus a chevron, with the name in the menu rows and in the tooltip, which doubles as its accessible name. The menu is a portal, so the specimen is the closed trigger at both rungs.

WebAgentPicker / AgentPickerTabspackages/ui/src/agent-picker.tsxAgentMenuItems is exported on its own for a row's "…" menu; agentLabel names an id this build does not ship
Desktopcoding_selects::agent_pickerapps/desktop/crates/ui/src/coding_selects.rsagent_menu_items is the shared row set, exactly like the web arm
iOSAgentPickerMenuapps/ios/ExpUI/Sources/GlassMenu.swiftAgentOptionsRow (UI/Agent/AgentOptionsRow.swift) is the composer's host
Androidn/aEXP-872 retired AgentPickerPill: Add account picks its agent with a PickerRow; every launch surface picks an account.

2 General components · Inputs & pickers

Emoji picker

emoji-picker

EXP-551: the picker the description toolbar and the comment composer share — a search field, a Recent row, then the dataset's groups. A pick hands the caller the UNICODE to insert, never a `:shortcode:`, and always the BASE record (EXP-600 dropped the skin-tone row on every client). The 36px cell IS the ghost icon button stretched across its grid column: only the colour-emoji face and the glyph size are the picker's own. The DATA is passed in — the app lazy-loads the generated dataset and keeps the per-device recents — and the ranked search is the shared rule the three natives mirror by hand. Category headers are the one surface that stays uppercase.

WebEmojiPicker / EmojiPickerPopoverpackages/ui/src/emoji-picker.tsxEmoji picker category headers stay uppercase on purpose (shared exception).
Desktopemoji_picker::EmojiPickerapps/desktop/crates/ui/src/emoji_picker.rsemoji_picker_popover is the shared trigger; the grid is 8 cells wide, the popover's width driver
iOSEmojiPickerSheetapps/ios/Exponential/UI/Markdown/EmojiPickerSheet.swiftthe index and recents live in ExpUI/Sources/EmojiCatalog.swift
AndroidEmojiPickerSheetapps/android/app/src/main/java/com/exponential/app/ui/emoji/EmojiPickerSheet.ktrememberEmojiData / rememberEmojiPrefs are its dataset and recents halves

2 General components · Inputs & pickers · EXP-1021

Picker

picker

THE picker primitive: one surface per platform (a popover at the trigger on a pointer, a bottom sheet of plain rows on a phone), single or multi, optional search. Presentation belongs to the primitive, never to the caller — a row is a glyph or a dot, a label and a muted second line, and it is never a card. The selection language is the whole point of EXP-1021: a single pick wears a trailing check, a MULTI pick reads as the row's own highlight, never a leading circle, so the picker that links a relation and the picker that batches issues finally look like one thing. The trigger is the caller's (four shapes: pill, field, row, inline); the surface, the search field and the keys are the primitive's.

WebPickerpackages/ui/src/picker/picker.tsx
Desktoppicker::Pickerapps/desktop/crates/ui/src/picker/mod.rs
iOSGlassPickerapps/ios/ExpUI/Sources/Picker/Picker.swiftSwiftUI owns the bare name Picker, so the primitive is GlassPicker; the ten typed ones keep theirs.
AndroidPickerapps/android/app/src/main/java/com/exponential/app/ui/components/picker/Picker.kt

2 General components · Inputs & pickers · EXP-1021

Board picker

picker-board

Boards by icon and colour, everywhere a board is picked: the create-issue dialog's prefix chip, move-to-board (the issue detail, the list row's context menu, the phone properties sheet) and the automation trigger's board filter, which is the same picker in its multi arm. A board with no icon of its own is never a bare colour dot — a repo-backed board draws the repo glyph, everything else the default board glyph (EXP-449), decided once in `boardPickerItems`.

WebBoardPickerpackages/ui/src/picker/board-picker.tsx
Desktoppicker::board_pickerapps/desktop/crates/ui/src/picker/board_picker.rs
iOSBoardPickerapps/ios/ExpUI/Sources/Picker/BoardPicker.swift
AndroidBoardPickerapps/android/app/src/main/java/com/exponential/app/ui/components/picker/BoardPicker.kt

2 General components · Inputs & pickers · EXP-1021

Issue picker

picker-issue

Issues by identifier and title, single or multi. Single is the relations linker (sub-issue, blocks, duplicate — the surface this whole issue was measured against), the mark-as-duplicate flow and the stack dialog; multi is the composer picking a batch. Over a big board the ranking is the shared engine's (`useIssueSearchResults`, EXP-892) and the picker renders what it is handed, verbatim — so `shouldFilter` goes off and the top row stays selected as results arrive.

WebIssuePickerpackages/ui/src/picker/issue-picker.tsx
Desktoppicker::issue_pickerapps/desktop/crates/ui/src/picker/issue_picker.rs
iOSIssuePickerapps/ios/ExpUI/Sources/Picker/IssuePicker.swift
AndroidIssuePickerapps/android/app/src/main/java/com/exponential/app/ui/components/picker/IssuePicker.kt

2 General components · Inputs & pickers · EXP-1021

Action picker

picker-action

The team's actions (and the two listed builtins) by curated icon and name — the Agent composer's action chip and the automation editor's "Action" row. An action whose inputs are required is rendered and disabled in the automation arm, because an automated run has nobody to fill them in; a row with no icon of its own draws the default action glyph rather than nothing.

WebActionPickerpackages/ui/src/picker/action-picker.tsx
Desktoppicker::action_pickerapps/desktop/crates/ui/src/picker/action_picker.rs
iOSActionPickerapps/ios/ExpUI/Sources/Picker/ActionPicker.swift
AndroidActionPickerapps/android/app/src/main/java/com/exponential/app/ui/components/picker/ActionPicker.kt

2 General components · Inputs & pickers · EXP-1021

Account picker

picker-account

EXP-872: ONE picker for the agent AND its login — there is no separate agent pick. The list is every signed-in login the machine reports across both agents, each row the agent's brand mark beside the login's EMAIL (never the profile name, never the word "default": the last used login is simply the first row), and picking one implies its agent. A dead credential rides as a muted hint. EXP-992 adds the rate-limit preview — three small bars (5h / week / the model window) off the option's fractions, right of the row on a pointer, inline under the email in the sheet.

WebAccountPickerpackages/ui/src/picker/account-picker.tsx
Desktoppicker::account_pickerapps/desktop/crates/ui/src/picker/account_picker.rs
iOSAccountPickerapps/ios/ExpUI/Sources/Picker/SharedAccountPicker.swift
AndroidAccountPickerapps/android/app/src/main/java/com/exponential/app/ui/components/picker/AccountPicker.kt

2 General components · Inputs & pickers · EXP-1021

Device picker

picker-device

The machines a run may start on, each by its device glyph and name — the Agent composer and an action trigger's "Runs on" row. A machine that cannot take the run is rendered DISABLED with the reason as its muted second line, never dropped: a list that silently shrinks reads as a bug on the machine the user was looking for. A device with no icon of its own falls back to its kind (a server glyph for a server).

WebDevicePickerpackages/ui/src/picker/device-picker.tsx
Desktoppicker::device_pickerapps/desktop/crates/ui/src/picker/device_picker.rs
iOSDevicePickerapps/ios/ExpUI/Sources/Picker/DevicePicker.swift
AndroidDevicePickerapps/android/app/src/main/java/com/exponential/app/ui/components/picker/DevicePicker.kt

2 General components · Inputs & pickers · EXP-1021

Assignee picker

picker-assignee

The team's members by AVATAR, name and email — the issue properties, the create-issue dialog, the list row's context menu and the bulk bar. The avatar is the one row body the primitive does not draw itself (a picker glyph is an icon, never a photo), and the email is both the muted second line and a search term. `Unassigned` is a row like any other and reports `null`: no `__unassign__` sentinel exists anywhere. On a solo team the control is hidden entirely — there is nobody to assign to.

WebAssigneePickerpackages/ui/src/picker/assignee-picker.tsx
Desktoppicker::assignee_pickerapps/desktop/crates/ui/src/picker/assignee_picker.rs
iOSAssigneePickerapps/ios/ExpUI/Sources/Picker/AssigneePicker.swift
AndroidAssigneePickerapps/android/app/src/main/java/com/exponential/app/ui/components/picker/AssigneePicker.kt

2 General components · Inputs & pickers · EXP-1021

Icon picker

picker-icon

EXP-575/EXP-924: the one icon picker, and the SET is a parameter — the curated board icons unless a surface names another (the device set). It is the primitive with a `panel`: the swatch grid REPLACES the search field and the rows, so the surface, its sheet and its trigger stay the picker's while the body is a grid of squares. The trigger is that shape rule: an icon-only ACTION is a circle, an icon PICKER is a rounded square, because it previews a swatch. Both sets are APPEND-ONLY (contract `boardIcon` / `deviceIcon`): reordering orphans rows.

WebIconPickerpackages/ui/src/picker/icon-picker.tsx
Desktoppicker::icon_pickerapps/desktop/crates/ui/src/picker/icon_picker.rs
iOSIconPickerapps/ios/ExpUI/Sources/Picker/SharedIconPicker.swift
AndroidIconPickerapps/android/app/src/main/java/com/exponential/app/ui/components/picker/IconPicker.kt

2 General components · Inputs & pickers · EXP-1021

Status picker

picker-status

The team's own `issue_statuses` rows (EXP-314) in display order, each by its glyph in its colour — the issue header chip, the create dialog, the phone properties row, the list row's context menu, the bulk bar and the PR-automation target. Six builtins are LOCKED (never renamed, recoloured or deleted) and a custom row is name + colour; the picker takes the RESOLVED row, so the builtin's token class and the custom's hex reach it through one slot. The duplicate CATEGORY is never offered here: picking it opens the canonical-issue picker instead.

WebStatusPickerpackages/ui/src/picker/status-picker.tsx
Desktoppicker::status_pickerapps/desktop/crates/ui/src/picker/status_picker.rs
iOSStatusPickerapps/ios/ExpUI/Sources/Picker/StatusPicker.swift
AndroidStatusPickerapps/android/app/src/main/java/com/exponential/app/ui/components/picker/StatusPicker.kt

2 General components · Inputs & pickers · EXP-1021

Priority picker

picker-priority

The contract's five priorities in display order, each by its glyph in its tone — the issue's priority chip and button, the create dialog, the phone properties row, the context menu, the bulk bar and the automation trigger's priority filter (the same picker, multi, capped). No search: five fixed rows are a menu, not a list. The TABLE is the app's, beside the enum — the picker is handed rows and never owns the vocabulary, which is why the trigger draws the RESOLVED config rather than a matched row (an unknown forward-compat value falls back instead of rendering blank).

WebPriorityPickerpackages/ui/src/picker/priority-picker.tsx
Desktoppicker::priority_pickerapps/desktop/crates/ui/src/picker/priority_picker.rs
iOSPriorityPickerapps/ios/ExpUI/Sources/Picker/PriorityPicker.swift
AndroidPriorityPickerapps/android/app/src/main/java/com/exponential/app/ui/components/picker/PriorityPicker.kt

2 General components · Inputs & pickers · EXP-1021

Label picker

picker-label

ALWAYS multi, always searchable, each row its colour dot and name — the issue properties, the create dialog, the context menu, the bulk bar, the widget's default labels and the automation trigger's label filter. This is the picker the multi selection language was designed on: the surface stays open across toggles (a batch is several picks) and every picked row reads as the row's OWN highlight, never a leading circle or a checkbox. Two primitive slots carry the rest: "Create label" is the `footer`, and the create form is the `panel` that replaces the search field and the rows while it is up.

WebLabelPickerpackages/ui/src/picker/label-picker.tsx
Desktoppicker::label_pickerapps/desktop/crates/ui/src/picker/label_picker.rs
iOSLabelPickerapps/ios/ExpUI/Sources/Picker/LabelPicker.swift
AndroidLabelPickerapps/android/app/src/main/java/com/exponential/app/ui/components/picker/LabelPicker.kt

2 General components · Inputs & pickers · EXP-792

MCP server picker

picker-mcp

The team's MCP servers a run connects to — the Agent composer's "MCP servers" row (multi) and, as the same rows in a PickerList, the Add dialog's one search box over the well-known servers (type a name, or paste any URL and pick its "Use …" row). A server whose host is in the catalog (contract mcpCatalog) wears the REAL brand mark of its service, one of the vendored selfh.st light SVGs in packages/icons brand, reproduced as-is and never recoloured; a command server the terminal, anything else the plug. One the member has not connected renders with "Connect first" as its second line rather than vanishing.

WebMcpServerPickerpackages/ui/src/picker/mcp-server-picker.tsx
Desktoppicker::mcp_server_pickerapps/desktop/crates/ui/src/picker/mcp_server_picker.rs
iOSn/aThe native composer has no MCP row yet; its runs connect none.
Androidn/aThe native composer has no MCP row yet; its runs connect none.

2 General components · Inputs & pickers · SLOP-7

Repository picker

picker-repository

The repositories a board can code against, as owner/name rows with the GitHub glyph (SLOP-7). A private repo carries a lock; a trailing tag says why a row sits where it does — "matches board" in green ranks first, "used by Website" (muted) names the first OTHER board it already backs. One search field on top; the footer row leaves for GitHub to add another. Ranking and words are the fixture-locked coding-readiness.json picker cases ×4.

WebRepositoryPickerListpackages/ui/src/repository-picker.tsx
DesktopAddRepositoryDialogViewapps/desktop/crates/ui/src/settings/add_repository_dialog.rsThe readiness popover's inline picker ranks with coding_readiness::picker_rows.
iOSGithubRepoPickerapps/ios/Exponential/UI/Settings/GithubRepoPicker.swift
AndroidGithubRepoPickerSheetapps/android/app/src/main/java/com/exponential/app/ui/onboarding/GithubRepoPickerSheet.kt

2 General components · Inputs & pickers · FEED-76

Scope picker

scope-picker

What a credential may touch: an "Everything" switch (all teams and boards, including ones created later) over the member's teams, each a whole-team checkbox with its boards indented beneath — a board under a ticked team reads checked and disabled because the team covers it. The MCP OAuth consent screen and the Create-API-key dialog show the same control; a scoped API key is MCP-only and the key row wears the pick as "Scoped to Acme, Web (WEB)".

WebScopePickerpackages/ui/src/scope-picker.tsx
DesktopScopePickerapps/desktop/crates/ui/src/scope_picker.rsThe Create-key alert's Access block; the key row carries the scope caption (users.rs scope_caption).
iOSn/aNo key or consent UI on iOS; the consent page is web-only mid-authorize.
Androidn/aNo key or consent UI on Android; the consent page is web-only mid-authorize.

2 General components · Buttons & chips

Rich tab

rich-tab

The STRIP tab — the desktop's top tab strip and session bar, the web agent dock. 32 tall, radius 6, capped at 240, padding left 8 and right 4 (the short side is the 24px ghost X beside it; 8 on a chip that carries none), gap 6; no chrome at rest, hover and active both take the active fill and full foreground, active adds the card hairline. ONE 14px lead box holds either a 14px status glyph or the 8px liveness dot, so a run chip's title lines up with an issue chip's; the mono identifier is text-xs, the title truncates at 180, an exit code rides a small badge. Never a pill: pills carry a label, this carries a state. EXP-923: every chip closes again and the strip is a flat list of them — the live agent CLUSTERS are gone with the live tabs, which are rail rows now.

WebRichTabpackages/ui/src/rich-tab.tsx
Desktopsurface::rich_tabapps/desktop/crates/ui/src/surface.rs
iOSn/ano terminal or top tab strips
Androidn/ano terminal or top tab strips

2 General components · Buttons & chips

Primary icon button

icon-button

A 32px circle of card fill under a card stroke, glyph 16px at 70% foreground; hover fills to active and the glyph goes full strength. The SHAPE is the meaning (EXP-771, narrowed by EXP-862): a circle marks the PRIMARY action and nothing else wears one. That is play / start, send, the rail's New issue and Search, a mobile FAB, and the "+" that adds. Every other icon-only control is the ghost icon button. The remaining exception is a picker TRIGGER, which is a rounded square: see icon picker.

WebbuttonVariants variant="glass" size="icon-sm"packages/ui/src/button.tsx
Desktopcontrols::glass_icon_buttonapps/desktop/crates/ui/src/controls.rs
iOSCircleIconButtonapps/ios/ExpUI/Sources/GlassControls.swift
AndroidCircleIconButtonapps/android/app/src/main/java/com/exponential/app/ui/components/CircleIconButton.kt

2 General components · Buttons & chips

Ghost icon button

ghost-icon-button

The SECONDARY icon button (EXP-862): the same 32px box and the same 16px glyph at 70% foreground, with no circle, no fill and no border at rest. Hover is the only paint it carries, the row wash under the MD corner, and the glyph goes full strength; a toggle that is ON says so with aria-pressed and keeps that wash (the editor rail's marks, EXP-960). Everything that is not the primary action wears this one: the "…" overflow, close, the folder and file-list toggles, the chevrons (back, fold, reorder), trash and remove, refresh. Put a circle here and the surface ends up with three things asking to be pressed and no way to tell which one it wants.

WebbuttonVariants variant="ghost" size="icon-sm"packages/ui/src/button.tsx
Desktopcontrols::ghost_icon_buttonapps/desktop/crates/ui/src/controls.rs
iOSGhostIconButtonapps/ios/ExpUI/Sources/GlassControls.swift
AndroidCircleIconButton(borderless = true)apps/android/app/src/main/java/com/exponential/app/ui/components/CircleIconButton.ktone composable, two shapes: borderless drops the circle and the stroke and keeps the hover fill

2 General components · Buttons & chips

Floating circle

fab-button

The third circle, and the only one that FLOATS: 52px of the floating chrome around a 20px glyph, pressed down to the active wash. EXP-962 made it a component — three files had restated the circle, one of them spelling 52px as 3.25rem — so the tab bar's FAB, the issue bar's coding circle and every slot of the Work bar are now one button. Its glyph rides at the SECONDARY emphasis, 70% white, because a bar of equal circles has no hierarchy to spend; the one slot that IS the bar's call to action says `emphasis="primary"` and goes full white. The 32px icon button above lives in a row; this one hangs over the content, and only a phone has one. `FAB_CIRCLE_CLASS` is the same shape for the two slots that are not buttons (the usage ring, the capsule).

WebFabButton / FAB_CIRCLE_CLASSpackages/ui/src/fab-chrome.tsxMOBILE_WORK_CIRCLE_CLASS in mobile-work-bar.tsx is an alias of the class
Desktopn/ano floating phone bar: the IDE's bottom edge is the terminal session bar
iOSFloatingBarCircleapps/ios/ExpUI/Sources/FloatingBottomBar.swiftthe glyph's emphasis rides the caller's tint rather than a parameter
AndroidFabapps/android/app/src/main/java/com/exponential/app/ui/components/BottomNavBar.ktthe tab bar's FAB paints its 52dp circle inline: opaque fill, strong hairline, a full-white glyph

2 General components · Buttons & chips

Primary submit

button-primary

Full width, padding 14/16, radius 10, solid primary. Disabled drops to card fill with a card stroke and 50% foreground. EXP-1176: ONE shape on every client — the specimen is the web's own Button, a radius-10 rectangle like the mobile sheet submit and desktop web_md. A text button is never a capsule; the capsule is the pill.

WebButton (variant default)packages/ui/src/button.tsxthe same radius-10 rectangle on every client since EXP-1176; the mobile sheet submit is the full-width form of it
Desktopsurface::glass_pill_button_primaryapps/desktop/crates/ui/src/surface.rs
iOSGlassSubmitButtonapps/ios/ExpUI/Sources/GlassControls.swift
AndroidGlassSubmitButtonapps/android/app/src/main/java/com/exponential/app/ui/components/GlassSubmitButton.kt

2 General components · Buttons & chips

Text button

text-button

EXP-962: a control made of WORDS — `size="inline"`, 12px, no box, no height of its own, sitting in the run of muted text around it. Two variants, and the difference is what happens when it is pressed: `text` is muted, brightens under the pointer and never underlines, because it toggles something IN PLACE (a fold's Show more / Show less, "Back to the current step"); `link` takes the primary colour and underlines on hover, because it GOES somewhere (a session band's "Continues in a newer run", a stack band's `↓ #APP-14`). Anything that wants a box is the pill or the primary submit — four call sites hand-drew one of these two shapes before.

WebButton variant="text" / variant="link", size="inline"packages/ui/src/button.tsx
Desktopcontrols::text_button (TextButtonVariant::Text)apps/desktop/crates/ui/src/controls.rsEXP-963: the output card's and the body's Show more, the edited-files footer ride it
iOSButton("Show more").buttonStyle(.plain)apps/ios/Exponential/UI/Session/AgentSessionView.swiftinlined twice with its own caption2 font and tertiary opacity; no shared text button exists
AndroidShowMoreToggleapps/android/app/src/main/java/com/exponential/app/ui/session/AgentSessionScreen.kta private clickable Text on the session screen; nothing else may reach it

2 General components · Buttons & chips

Pill

pill

The ONE capsule, a 2×3 matrix: size md 32 or sm 24, mode action / select / readonly, plus a primary PAINT flag that crosses all six. Card fill under a card stroke, label at 70% — action and select go active on hover, a selected one also takes the active stroke, readonly is metadata and never a target. There is no chip and no header button: those WERE this, under a second name. A conversation or subagent tab is sm select; a members-list role chip is sm readonly, 12px from its neighbours in a row. A bare COUNT is none of the six: a number with no word beside it is the 16px `Badge` below.

WebPillpackages/ui/src/pill.tsx
Desktopsurface::glass_pillapps/desktop/crates/ui/src/surface.rs
iOSGlassPillapps/ios/ExpUI/Sources/GlassPill.swift
AndroidGlassPillapps/android/app/src/main/java/com/exponential/app/ui/components/GlassPill.kt

2 General components · Buttons & chips

Count badge

badge

EXP-962: the smallest chip there is — a 16px capsule carrying a NUMBER and nothing else, 10px semibold and tabular so a count can climb without the box twitching. `muted` is a count you parked (the rail's drafts), `primary` one that wants you (unread). Zero renders NOTHING, because a badge is a signal and an empty signal is noise, and past `max` it reads `99+`. PLACEMENT stays at the call site — a row's trailing edge, a nav glyph's corner — so the badge owns only its shape. A `Pill size="sm"` is 24 tall and carries a word; this carries a quantity.

WebBadgepackages/ui/src/badge.tsx
Desktopsurface::count_badgeapps/desktop/crates/ui/src/surface.rsEXP-963: RailBadge::Count hangs it on the rail's Drafts entry
iOSGlassSegmentedControlapps/ios/ExpUI/Sources/GlassSegmentedControl.swiftthe one count capsule is inlined in a segment; the tab bar's unread mark is a FloatingBarBadgeDot
AndroidGlassSegmentedControlapps/android/app/src/main/java/com/exponential/app/ui/components/GlassSegmentedControl.ktthe same inline capsule (BadgeFill) inside a segment, reachable by nothing else

2 General components · Buttons & chips

Issue chip

issue-chip

The ONE badge that names an issue inline. A small rounded RECT — 6px on web, 4 on desktop, 5 on iOS, 5dp on Android — and never a capsule: a capsule is the pill, which carries a label, not a subject. A hairline border over the accent fill (the fill barely clears the surface, so the border is what makes the chip legible), then status glyph · mono muted identifier · the title in the foreground at medium weight, truncated. The left padding is tighter than the right because the glyph carries its own gap. The ✕ sits INSIDE the chip and exists for COMPOSERS only — nothing else hands a badge a control — and it takes the trailing padding down to its own hit box. Inside a markdown editor the identical box is a DECORATION painted over the bare `#IDENT` token, so the document text round-trips untouched and the two cannot drift. SLOP-15: the SMALL mode (`size="sm"`) is the same box without the title — glyph · identifier — for the surfaces that name many issues at a glance (the hover graph's nodes, the work header's badge on a phone, the badge overlay's rows); the title rides in the tooltip and the preview card.

WebIssueChippackages/ui/src/issue-chip.tsxThe app's components/issue-chip.tsx is the binding that resolves the status and adds the hover preview.
Desktopissue_chipapps/desktop/crates/ui/src/issue_chip.rs
iOSIssueChipapps/ios/ExpUI/Sources/IssueChip.swift
AndroidIssueChipapps/android/app/src/main/java/com/exponential/app/ui/components/IssueChip.kt

2 General components · Buttons & chips

Entity chip

entity-chip

The issue chip's box, opened to every kind an Exponential MCP answer can name (EXP-920): a settled tool row in a run transcript draws ONE chip per entity it touched — board, action, comment, run, label, status, workflow, device, member… — as a glyph and a short label in the SAME rounded rect the issue chip owns (the issue chip now renders THROUGH it, so the two cannot drift by a pixel). An issue keeps its three parts (status glyph · mono identifier · title); every other kind draws its icon CONCEPT (`entityRefIcon`) and its name; a LIST answer folds into one chip that counts its members in the product noun (`3 issues`, `1 run`) and never navigates — its card lists them. Label, detail and grouping are the contract's (`@exp/domain-contract/entity-preview`, fixture-locked ×4). A row the viewer has not synced draws the same chip muted with no target and no card.

WebEntityChippackages/ui/src/entity-chip.tsxThe app's components/entity-preview/ resolves the row, the target and the hover card per kind.
Desktopentity_chipapps/desktop/crates/ui/src/entity_chip.rs
iOSEntityChipapps/ios/ExpUI/Sources/EntityChip.swift
AndroidEntityChipapps/android/app/src/main/java/com/exponential/app/ui/components/EntityChip.kt

2 General components · Buttons & chips

Avatar

avatar

Picture first: a circle filled edge to edge by the person's image. Without one the initials sit on THEIR hue — one of eight token colours picked by fnv1a32(utf8(userId)) % 8 — as a 20% fill under the glyph at full strength, no stroke. The hash is byte-identical on all four clients, so one person is one colour everywhere; a subject with no id at all (a bot, an unresolved reporter) keeps the muted fallback.

WebUserAvatarpackages/ui/src/user-avatar.tsxAvatarFallback (./avatar.tsx) paints the hue; UserAvatar is the composition every site renders.
Desktopuser_avatar::avatar_elementapps/desktop/crates/ui/src/user_avatar.rs
iOSUserAvatarapps/ios/ExpUI/Sources/UserAvatar.swift
AndroidUserAvatarapps/android/app/src/main/java/com/exponential/app/ui/components/Avatars.kt

2 General components · Buttons & chips

Icon disc

icon-disc

A 48px circle holding a 24px glyph. It is a HEADING, not a control — it never takes a click and it has no smaller size: it opens an empty state, a wizard step card, an invite page or a full-page outcome, and nothing else. The TONE owns both the wash and the glyph colour, so a call site never restates a text-* on the icon: primary at 10% for a neutral head, emerald, red and muted at 15% for an outcome. Only placement stays outside — the mx-auto that centres it in a card and whatever margin the copy under it needs. EXP-903: eight web surfaces drew this circle by hand before it became one primitive.

WebIconDiscpackages/ui/src/icon-disc.tsx
DesktopOnboardingView::renderapps/desktop/crates/ui/src/onboarding.rsthe wizard card head draws the 48px primary disc inline — the only disc on the IDE, so it has no shared helper
iOSn/athe wizard step header is a title over a subtitle; no native screen opens with a disc
Androidn/athe onboarding step head is a 56dp glassCard box around a primary glyph, not a tinted circle
Still drawn by hand
  • apps/web/src/components/inbox/inbox-view.tsxa hand-rolled 28px muted disc

2 General components · Buttons & chips

Attachment thumbnail

attachment-thumb

A picked attachment waiting in a composer: a 64px center-cropped tile at radius MD under the card hairline, with a small circular remove badge hung off its top-right corner. A video uses the same tile with a first-frame poster on black; any other file stays a chip that carries the same badge. EXP-904: the comment, launch and steer composers each drew it by copy-paste. EXP-962 gave it two SIZES and two ARMS: `inline` is the image as POSTED — its own width, capped at 480 tall, contained on the section fill under the same hairline, reserving its probed aspect ratio — and the arms are `onOpen`, which makes the media a zoom button into the lightbox, and `onRemove`, the corner badge, which a posted image shows only on hover.

WebAttachmentThumb / AttachmentRemoveButtonpackages/ui/src/attachment-thumb.tsx
Desktopcomment_attachments::pending_attachments_stripapps/desktop/crates/ui/src/comment_attachments.rs
iOSPendingAttachmentStripapps/ios/Exponential/UI/Components/AttachmentStrips.swift
AndroidPendingAttachmentStripapps/android/app/src/main/java/com/exponential/app/ui/components/AttachmentStrips.kt

2 General components · Buttons & chips

Team avatar

team-avatar

A team is a single accent SQUARE — the primary fill under its foreground, corners at a quarter of the side, the first letter at 44% — so it never reads as a person: user avatars are hashed-hue circles and there is no hue here to hash, because a team has one identity, not one of eight. 28 in the sidebar header, 20 in its menu, 18 in the switcher sheet.

WebTeamAvatarpackages/ui/src/team-avatar.tsx
Desktopuser_avatar::team_avatarapps/desktop/crates/ui/src/user_avatar.rs
iOSTeamAvatarapps/ios/ExpUI/Sources/TeamAvatar.swift
AndroidTeamAvatarapps/android/app/src/main/java/com/exponential/app/ui/components/Avatars.kt

2 General components · Buttons & chips

Live dot & status glyph

live-dot

The two smallest marks the product has. The DOT carries a session's state in one 8px disc — the six tones are the ×4 table (live / attention / done / unread / idle / muted) — and only a LIVE one pulses, so a halo anywhere means something is happening right now. The status GLYPH is the issue's status as a pie clock: the category decides the shape, the row decides the colour (a token class for a builtin, the synced hex for a custom), and the identical glyph appears in the list, the chip and the picker.

WebLiveDot / StatusGlyphpackages/ui/src/live-dot.tsxthe glyph is status-glyph.tsx; LIVE_DOT_TONE is locked against the app's SESSION_DOT_CLASS
Desktopsurface::live_dotapps/desktop/crates/ui/src/surface.rsping = an animated halo, twice the disc, 60% to 0 over 1s on the decelerate curve; the run list pings on agent_busy only
iOSSessionStateDotapps/ios/Exponential/UI/Session/SessionStateDot.swiftthe halo is PulsingLiveDot; the status glyph is iconName/color in ExpUI/IssueColorExtensions.swift
AndroidLiveDotapps/android/app/src/main/java/com/exponential/app/ui/issue/AgentPrCard.ktPulsingDot / StaticDot sit in the same file; the status glyph is StatusIcon (ui/components/IssueVisuals.kt)

2 General components · Buttons & chips

Board glyph

board-glyph

EXP-449: a board is ALWAYS its curated icon tinted with its own colour — the anonymous colour dot is gone from every picker, breadcrumb, caption and switcher. The icon name is a Lucide name straight out of the shared registry (EXP-273), so all four clients resolve the same art; a board with none falls back on its shape, the code glyph for a repo-backed board and the kanban grid for the rest.

WebBoardGlyphpackages/ui/src/board-glyph.tsxgetBoardIcon in board-icons.ts is the name resolver
Desktopicons::board_glyphapps/desktop/crates/ui/src/icons.rsboard_icon is the glyph; the tint is applied at the call site (sidebar::rail_board_icon)
iOSBoardTypeDisplay.iconName(for:)apps/ios/ExpUI/Sources/BoardIconDisplay.swiftname resolution only: every call site applies its own .foregroundStyle(Color(hex:)), so the tint is copy-pasted
AndroidBoardIconapps/android/app/src/main/java/com/exponential/app/ui/components/BoardIconUi.kt

2 General components · Buttons & chips · EXP-1191

Jump to bottom

jump-to-bottom

The one control that returns a bottom-anchored feed (the run transcript) to its newest row, identical on all four clients: a 32 px circle in the floating glass (the popover fill, the card hairline, no shadow) holding only the down arrow (ui-arrow-down, 16 px, the secondary foreground). It sits centred over the feed, 12 px above the composer, fades and scales in while the reader is scrolled up and out once they are back at the tail. Click scrolls to the end and re-arms follow; the label "Jump to bottom" is its tooltip and accessible name.

WebJumpToBottomButtonpackages/ui/src/jump-to-bottom-button.tsx
Desktopcontrols::jump_to_bottom_buttonapps/desktop/crates/ui/src/controls.rs
iOSJumpToBottomButtonapps/ios/ExpUI/Sources/JumpToBottomButton.swift
AndroidJumpToBottomButtonapps/android/app/src/main/java/com/exponential/app/ui/components/JumpToBottomButton.kt

2 General components · Lists & rows

Group band

section-header

EXP-818: the Linear group header — a full-width strip on the section fill, radius 10, padding 6/12, 14/20 at 85% foreground, a trailing slot, 4px over its flat rows. No count. Never uppercase and never a divider. A band heads a LIST; the bare fold INSIDE a row is the disclosure header below, which draws no strip at all.

WebGlassSectionHeaderpackages/ui/src/glass-rows.tsxEmoji picker category headers stay uppercase on purpose (shared exception).
Desktopsurface::glass_section_headerapps/desktop/crates/ui/src/surface.rsEmoji picker category headers stay uppercase on purpose (shared exception).
iOSGlassSectionBandapps/ios/ExpUI/Sources/GlassTheme.swiftEmoji picker category headers stay uppercase on purpose (shared exception).
AndroidModifier.glassSectionBand()apps/android/app/src/main/java/com/exponential/app/ui/theme/Glass.ktSectionHeader (Scaffolding.kt) wraps it. Emoji picker category headers stay uppercase on purpose (shared exception).

2 General components · Lists & rows

Disclosure header

disclosure-header

EXP-962: the fold toggle INSIDE a row, and the whole of it is one line of bare text — a 12px chevron pointing right folded and down open, the label muted and brightening under the pointer, `aria-expanded` stating the fold, the entire line the target. The steer feed's tool groups, its Exponential calls, its subagent lanes and its long bodies, the workflow card's agents and the issue group's own header each drew this by hand before it was one component. `chevron="trailing"` parks the glyph at the far edge instead, for a row whose siblings carry none and must not indent out of line with them. It is NOT the group band above: that is a filled strip heading a LIST. And it may not contain another button — a fold's own action renders beside it, because a button inside a button is invalid markup.

WebDisclosureHeaderpackages/ui/src/disclosure-header.tsx
Desktopcontrols::disclosure_headerapps/desktop/crates/ui/src/controls.rsEXP-963: the steer feed's tool groups, Exponential runs, subagent lanes and workflow agents fold on it
iOSToolGroupRow / ExpToolGroupRow / SubagentGroupRowapps/ios/Exponential/UI/Session/AgentSessionView.swiftthree private structs repeat the 11pt chevron row, each with its own @State expanded
AndroidToolGroupRow / ExpToolGroupRow / SubagentGroupRowapps/android/app/src/main/java/com/exponential/app/ui/session/AgentSessionScreen.ktthe same three private composables, each rebuilding the chevron row

2 General components · Lists & rows

Group container

group

EXP-994: THE settings shell, one per platform — borderless: radius 12, the row fill, a hairline BETWEEN every pair of children, overflow hidden. The fill is the edge, never an outer stroke, and never a card inside a card: inside an overlay that already is a surface (the composer's ⋯ popover, a bottom sheet) the group goes BARE — dividers only, no fill, no radius — so the host's edge is the only edge. Every grouped settings list on every platform draws this; the second specimen is the bare form.

WebGlassGrouppackages/ui/src/glass-rows.tsx
Desktopsurface::glass_group / glass_group_rowsapps/desktop/crates/ui/src/surface.rs
iOSGlassSectionapps/ios/ExpUI/Sources/GlassTheme.swift
AndroidModifier.glassGroup()apps/android/app/src/main/java/com/exponential/app/ui/theme/Glass.ktOptionGroup in ui/components/SheetOptionRows.kt is the list wrapper around it.

2 General components · Lists & rows

Property row

property-row

EXP-1170: THE phone properties sheet row (issue detail + create form, ×3 phones): the label left and muted, the value right and readable with its glyph riding beside it as one trailing unit, the WHOLE row the target, no chevron. Rows stack inside a `GlassGroup`. A set-valued property (Labels) shows the picks joined by ", " in the team's order and opens the shared picker sheet, never a cloud of toggle chips; nothing picked reads "None".

WebPropertyRowpackages/ui/src/property-row.tsx
Desktopn/aDesktop shows the properties inline as header chips; no sheet rows.
iOSGlassMetaRowapps/ios/ExpUI/Sources/GlassMetaRow.swift
AndroidMetaRowapps/android/app/src/main/java/com/exponential/app/ui/components/SheetOptionRows.kt

2 General components · Lists & rows

Glass row

row

The GAPPED card item: radius 10, row fill, its own hairline border, padding 12. EXP-818/1076 keep it for REAL CARDS only (a transcript's tool output, a diff) — never a settings list: the settings ladder is a `GlassSectionHeader` band over `SETTINGS_LIST_CLASS` + the flat list row below.

WebGlassRowpackages/ui/src/glass-rows.tsx
Desktopsurface::glass_row_cardapps/desktop/crates/ui/src/surface.rs
iOSGlassRowapps/ios/ExpUI/Sources/GlassTheme.swift
AndroidModifier.glassRow()apps/android/app/src/main/java/com/exponential/app/ui/theme/Glass.kt

2 General components · Lists & rows

List row

list-row

EXP-818: the flat list item every list wears — no stroke, no fill, radius 10, padding 12, NO gap between rows under a group band; hover takes the row fill, the selected row the active fill. Rows read as a table, not as cards. EXP-962 gave it a second density: `compact` is the 28px one-line row the narrow column runs at (the sidebar's pinned and draft arms, the compact inbox) — the same 14px type, 8px of side padding, 8px to the glyph — and `SidebarMenuButton density="compact"` is its exact twin, so a nav entry and a list row sitting in the same 17rem slot are the same height.

WebListRowpackages/ui/src/glass-rows.tsx
Desktopsurface::flat_row / flat_row_compactapps/desktop/crates/ui/src/surface.rsEXP-963: flat_row_compact is the 28px density the rail's entries run at
iOSFlatRow / .flatRow()apps/ios/ExpUI/Sources/GlassTheme.swift
AndroidModifier.flatRow()apps/android/app/src/main/java/com/exponential/app/ui/theme/Glass.kt
Still drawn by hand
  • apps/web/src/components/team/board-switcher-sheet.tsxPLAIN_ROW re-derives the mobile picker row

2 General components · Lists & rows

Tree guides

tree-guides

EXP-965: the connector every NESTED list draws instead of bare indentation. A row used to hang under its parent by left padding alone, so three levels of runs read as three arbitrary margins. The indent stays 14px per level; on top of it a row at depth d draws, in its PARENT's 14px gutter, a 1px vertical from its top edge to its vertical centre, a rounded elbow (radius 5) and a stub out to the gutter's right edge — and the vertical carries on to the bottom edge when a sibling follows (a tee). Every ancestor level whose subtree continues below draws a straight full-height line, so a deep child stays attached to every level above it. One hairline weight throughout (the strong glass stroke); a parent draws nothing of its own, and a folded subtree draws nothing at all. The RULE is pure and shared ×4 — it reads nothing but the visible rows' depths — so only the painting is per-platform.

WebTreeGuides / treeGuidespackages/ui/src/tree-guides-view.tsxthe pure rule is tree-guides.ts; drawn as one absolutely positioned SVG inside the row
DesktopGuides / guides_forapps/desktop/crates/domain/src/tree_guides.rsdomain::tree_guides; the painter is crates/ui/src/tree_guides.rs
iOSTreeGuides / TreeGuidesOverlayapps/ios/ExpCore/Sources/Domain/TreeGuides.swiftthe overlay is ExpUI/Sources/TreeGuidesOverlay.swift
AndroidTreeGuides / TreeGuidesOverlayapps/android/app/src/main/java/com/exponential/app/domain/TreeGuides.ktthe overlay is ui/components/TreeGuidesOverlay.kt

2 General components · Lists & rows

Row shell

row-shell

The rhythm every grouped row inherits: padding 12/16, gap 12, 14px text. The shell never draws a stroke — the group's hairlines do.

WebGlassInputRow / GlassToggleRow / Combobox triggerVariant="row"packages/ui/src/glass-rows.tsx
Desktopsurface::glass_row_shellapps/desktop/crates/ui/src/surface.rs
iOSGlassPickerRowapps/ios/ExpUI/Sources/GlassOptionRows.swift
AndroidPickerRowapps/android/app/src/main/java/com/exponential/app/ui/components/SheetOptionRows.kt

2 General components · Lists & rows

Picker row

picker-row

Label left, value right-aligned at 70% foreground, a 14px chevron at 50%. The whole row is the target, never just the value.

WebCombobox triggerVariant="row"packages/ui/src/combobox.tsxEXP-958: the row IS the picker — its own Select and sheet are gone
Desktopsurface::glass_picker_rowapps/desktop/crates/ui/src/surface.rs
iOSGlassPickerRowapps/ios/ExpUI/Sources/GlassOptionRows.swift
AndroidPickerRowapps/android/app/src/main/java/com/exponential/app/ui/components/SheetOptionRows.kt

2 General components · Lists & rows

Input row

input-row

A bare right-aligned field at 70% foreground inside the shell — no box, no border. The row is the field's chrome.

WebGlassInputRowpackages/ui/src/glass-rows.tsx
Desktopsurface::glass_input_rowapps/desktop/crates/ui/src/surface.rs
iOSn/aForm text rows use the system field inside GlassSection
AndroidGlassTextField(bordered = false)apps/android/app/src/main/java/com/exponential/app/ui/components/GlassTextField.kt

2 General components · Lists & rows

Toggle row

toggle-row

Label, an optional 12px description at 50%, and a 36×20 switch: on is the primary track with a primary-foreground thumb, off the active fill.

WebGlassToggleRowpackages/ui/src/glass-rows.tsx
Desktopsurface::glass_toggle_rowapps/desktop/crates/ui/src/surface.rs
iOSGlassToggleStyleapps/ios/ExpUI/Sources/GlassControls.swift
AndroidSwitchRowapps/android/app/src/main/java/com/exponential/app/ui/components/SheetOptionRows.kt

2 General components · Lists & rows

Embedded tabs row

tabs-row

The segmented control as the FIRST row of a group: padding 8, full width, no fill and no stroke of its own.

WebGlassTabsRowpackages/ui/src/glass-rows.tsx
Desktopsurface::glass_tabs_rowapps/desktop/crates/ui/src/surface.rs
iOSGlassSegmentedControl(style: .embedded)apps/ios/ExpUI/Sources/GlassSegmentedControl.swift
AndroidGlassSegmentedControl(embedded = true)apps/android/app/src/main/java/com/exponential/app/ui/components/GlassSegmentedControl.kt

2 General components · Lists & rows

Issue group band

issue-group-band

EXP-862: ONE group header for every issue list — the board's big list and the sidebar's narrow ones. A fold chevron, the status glyph, the name, the group's FULL size (not the windowed row count), and the group's own trailing action outside the fold button, because a button inside a button is invalid markup. Two densities: `list` is the board page's sticky edge-to-edge band, whose backdrop blur is load-bearing (rows scroll under a translucent tint), `compact` the 17rem sidebar's rounded strip. The status is passed IN as a resolved glyph and the tint as a resolved wash, so the component never learns the team's status rows.

WebIssueGroupBandpackages/ui/src/issue-group-band.tsxcomponents/issue-group-header.tsx is the binding that resolves the glyph and the wash
DesktopIssueListView::render_group_headerapps/desktop/crates/ui/src/issue_list.rsrender_board_nav / render_my_issues_nav take the same band at the narrow column's density
iOSIssueListView.statusHeaderapps/ios/Exponential/UI/Issue/IssueListView.swiftprivate per screen; MyIssuesView.statusHeader is a second copy and does not fold at all
AndroidGroupHeaderapps/android/app/src/main/java/com/exponential/app/ui/myissues/MyIssuesScreen.ktprivate; the board list draws its own StatusHeader in ui/issue/IssueListScreen.kt

2 General components · Lists & rows

Inline run picture

session-inline-picture

EXP-1172: a screenshot `exponential_sessions_show` filed while the run worked, drawn in the run TRANSCRIPT under that call's row ("Showed picture"): the Results tile at a 240px base (fitted to the column by the same one-factor rule), the call's caption under it (else the label), the shared lightbox behind a tap. It is found by the attachment id the call's answer carried (the tool preview's `id`) in the synced `coding_sessions.results`, so it appears the moment the upload lands, at the call, newest at the bottom; a run of show calls keeps its tiles visible while its group is folded. On the Results face the same `inline` pictures fold under a collapsed `Earlier · N` disclosure below a topic's final tiles unless they are its only picture (the Session results entry above shows the fold). Fixture `session-inline.json` ×4.

WebSessionInlineResultTilepackages/ui/src/session-results-view.tsxagent-session.tsx ShownPicture places it under the exp tool row
Desktoprender_inline_pictureapps/desktop/crates/ui/src/steer_viewer.rsthe steer view method; Earlier band in session_results.rs
iOSSessionInlinePictureapps/ios/Exponential/UI/Work/SessionResultsFace.swiftAgentSessionView reads the run's results through the sessionResultsRaw environment value
AndroidExpToolPictureapps/android/app/src/main/java/com/exponential/app/ui/session/AgentSessionScreen.ktreuses ResultTile + ResultPreviewDialog from ui/work/ResultsFace.kt

2 General components · Surfaces

Glass card

glass-card

The ONE translucent card, and only its box: radius XL, the card hairline, the glass card fill. Everything a card owns on its own stays at the call site, because those genuinely differ — its padding, a blur, a shadow, and the divide-y + overflow hidden that turns the same box into a GROUP of rows. EXP-903: five surfaces painted it by copy-paste (the comment row, the agent AskCard, the usage card, the mobile issue properties sheet and the repo picker, which had drifted to the MD corner and the bare hairline). The shadcn Card derives its recipe from the same constant, so the two cannot disagree; a group of list ROWS is the group container instead, on the row fill with no outer stroke.

WebGlassCard / GLASS_CARD_CLASSpackages/ui/src/glass-card.tsxCard (./card.tsx) derives its own recipe from the same constant.
Desktopsurface::glass_cardapps/desktop/crates/ui/src/surface.rs
iOSGlassCardapps/ios/ExpUI/Sources/GlassTheme.swift
AndroidModifier.glassCard()apps/android/app/src/main/java/com/exponential/app/ui/theme/Glass.kt

2 General components · Surfaces

Floating chrome

fab-chrome

The ONE floating-glass recipe a phone's bottom bar is made of, and only its PAINT: the card hairline, the popover fill at 85% and a backdrop blur. Radius, text colour and layout stay at the call site, because a capsule and a radius-16 tray genuinely differ from a circle. EXP-904: four files restated the string before it became one constant. EXP-962 took the 52px CIRCLE out of that list — it is `FabButton` now (its own entry) — so what still composes this by hand is the capsule stretched between two circles and the tray the steer composer expands into.

WebFAB_CHROME_CLASSpackages/ui/src/fab-chrome.tsx
Desktopn/ano floating phone bar
iOSFloatingBarCircleapps/ios/ExpUI/Sources/FloatingBottomBar.swift
AndroidFabapps/android/app/src/main/java/com/exponential/app/ui/components/BottomNavBar.ktthe tab bar FAB paints the opaque fill + strong hairline inline; no shared modifier

2 General components · Surfaces

Sheet shell

sheet

Top radius 24 over the page's bottom gradient, a card hairline, a 36×4 grabber. Header gutter 20, content gutter 16. Dismissal is the grabber drag or the backdrop — the header's trailing slot holds an optional ACTION, never a Cancel — and the bottom carries exactly one primary.

New issue
BoardMobile app
StatusBacklog
WebSheetContent side="bottom"packages/ui/src/sheet.tsx
Desktopn/adialogs are OS windows
iOSGlassSheetChrome + GlassSheetTokensapps/ios/ExpUI/Sources/GlassSheet.swift
AndroidGlassSheet + GlassSheetDefaultsapps/android/app/src/main/java/com/exponential/app/ui/components/GlassSheet.kt

2 General components · Surfaces

Bottom tab bar

tab-bar

A floating capsule: padding 4 inside a strong hairline, over the OPAQUE card fill. Items are 44px circles; the active one takes the active fill. The detached slot is one 52px capsule with two arms, Start chat | New issue, split by a hairline, on every tab-bar route (New issue dims with no board). The chat arm opens the Agent page, the screen a phone OPENS on: it keeps the bar, and the arm is SELECTED there (the tabs' active circle inside the arm). New issue is the only screen pushed over the bar.

WebMobileTabBarapps/web/src/components/team/mobile-tab-bar.tsx
Desktopn/ano bottom bar
iOSMobileTabBarapps/ios/Exponential/UI/Navigation/MobileTabBar.swift
AndroidBottomNavBarapps/android/app/src/main/java/com/exponential/app/ui/components/BottomNavBar.kt

2 General components · Surfaces

Hairline divider

divider

One pixel of the row stroke. The only rule allowed inside a group, and the only one anywhere in the glass set.

WebSeparatorpackages/ui/src/separator.tsxInside a group the same hairline comes from GlassGroup's divide-y, not from a Separator element.
Desktopsurface::glass_row_dividerapps/desktop/crates/ui/src/surface.rs
iOSGlassDividerapps/ios/ExpUI/Sources/GlassTheme.swift
AndroidGroupDividerapps/android/app/src/main/java/com/exponential/app/ui/components/SheetOptionRows.kt

2 General components · Surfaces

Dialog

dialog

The centred modal: a radius-16 card on the OPAQUE card fill under a card hairline, a semibold title, one line of body, and a footer whose LAST button is the primary. Cancel is borderless — two boxed buttons side by side ask the reader to choose between two equals. On a phone the same component drops to the bottom sheet arm. A confirm or a choice is never this shell: it is the Prompt (EXP-1215). Hand-written here because a closed Radix portal renders nothing at all statically (PORTAL_ONLY_IDS).

Rename board
The identifier prefix stays; only the name changes.
WebDialog / DialogContentpackages/ui/src/dialog.tsxa confirm or a choice is the Prompt entry (EXP-1215), never this shell
Desktopnative_dialog::DialogShellapps/desktop/crates/ui/src/native_dialog.rsEXP-284: every IDE dialog is a real OS window, not an in-window overlay
iOSn/ano shared shell: a confirm is GlassAlert (the Prompt entry), a content dialog is the sheet
Androidn/asame: a confirm is GlassAlert (the Prompt entry), a content dialog is GlassSheet

2 General components · Surfaces

Prompt

prompt

THE confirm and choice prompt (EXP-1215): every "Delete X?", merge confirm and multi-answer choice on the web is this one card, the same on every width, its wording read from the contract fixture `prompts.json` (web mirror `apps/web/src/lib/prompts.ts`, the same strings on iOS and Android). A centred card over the dimmed scrim, no ✕ (scrim tap and Esc are its cancel path); ONE question as the title; a body line only for a fact the title cannot carry (what is deleted, a name, a count, a consequence for other people), never a restatement of the buttons; an optional content slot between the text and the row (the blocked-start graph, an input); ONE row of the 32px `md` Pill capsules; only when that row cannot fit do the pills stack, one per line at their own width, trailing-aligned, in reverse display order: the default on top, Cancel below it, a quiet destructive answer last (iOS `GlassAlertLayout.stacked`); never a two-row hybrid. Roles: `cancel` = the plain pill that only dismisses; `primary` = the safe/expected answer, the accent pill at the trailing edge; `default` = a plain pill that does something; `destructive` = the answer of a plain "Delete X?" confirm, the plain pill with a destructive label and tinted border next to Cancel (no primary, Cancel takes focus); `quietDestructive` = destructive TEXT set apart on the leading edge when a safe primary exists too. No solid red blocks, no full-width buttons; initial focus and Enter never land on a destructive answer; a busy answer keeps its label and shows the pill's spinner while the row and the dismiss paths lock. The natives draw the same card (GlassAlert); the IDE asks in native alert windows with the same words, roles and focus (EXP-1230, `AlertSpec::from_prompt`).

WebPrompt / PromptLayoutpackages/ui/src/prompt.tsx
Desktopnative_dialog::AlertSpec::from_promptapps/desktop/crates/ui/src/native_dialog.rsEXP-1230: the fixture's words, roles and focus (domain prompts.rs) in a native alert window
iOSGlassAlertapps/ios/ExpUI/Sources/GlassAlert.swift
AndroidGlassAlertapps/android/app/src/main/java/com/exponential/app/ui/components/GlassAlert.kt

2 General components · Surfaces

Column resize edge

resize-handle

EXP-1156: the grab strip on a resizable column's right edge — the sidebar on web at md+ and every IDE sidebar column. EXP-1163: beside the content card the strip IS the card's left edge: centred on its border, only as tall as the card, its hairline kept clear of the rounded corners. Dragging follows the pointer between the shared `sidebar` token bounds (272–560, the whole column never past half the window), ←/→ step it by 16, a double-click resets the panel to its default. The width is remembered PER PANEL on the machine, never synced. At rest it draws nothing; a 1px hairline centred in the 8px strip, over the card's border, shows on hover, focus and while dragging (the right specimen below). Phones have no sidebar, so no edge.

WebResizeHandlepackages/ui/src/resize-handle.tsxthe team sidebar mounts one (components/team/sidebar.tsx); widths in lib/sidebar-widths.ts
Desktopresize_edge::handleapps/desktop/crates/ui/src/resize_edge.rsdrag_capture tracks the drag; widths in ui_prefs::sidebar_width
iOSn/aphones have no sidebar column to resize
Androidn/aphones have no sidebar column to resize

2 General components · Surfaces · EXP-1074

Menu

menu

The one floating-menu surface (opaque card fill, hairline, radius 12, no blur, no shadow) and the one ROW recipe on it, in two densities from tokens.json `menu`: POINTER for web from md up (36 row · 8 pad · 8 gap · 16 glyph · 180–280 wide), TOUCH for mobile web, iOS and Android (48 row · 12 pad · 12 gap · 16 glyph · 180–280 wide). Every menu-like row on web reads the same --menu-* vars — context and dropdown items, their sub-triggers, Select, Command, the typeahead — so one change moves every menu. A destructive row is red and never fenced off by a divider (EXP-687).

WebMENU_ITEM_CLASSpackages/ui/src/menu-surface.ts--menu-* in styles.css mirror tokens.json; this specimen wears the same constants
Desktopmenu::pointerapps/desktop/crates/theme/src/tokens.generated.rsgpui-component's PopupMenu draws 26px rows / 8px pad from the crate; the tokens record 36 / 8
iOSGlassMenuTokensapps/ios/ExpUI/Sources/GlassMenu.swiftthe touch set: 48pt rows, 12pt padding
AndroidGlassMenuDefaultsapps/android/app/src/main/java/com/exponential/app/ui/components/GlassMenu.ktthe touch set: M3's 48dp rows, 12dp padding

2 General components · Feedback

Meter

meter

The ONE bar every usage surface draws — the rate-limit windows, the run's context, the mini line. A capsule track in the strong stroke with a capsule fill, and exactly three tones: foreground at 30% normally, the yellow semantic from 75%, the destructive from 95%. The height is the caller's (6px full, 4px mini); the tone is the only decision. Before EXP-909 there were two bars two rows apart — a bare Progress with the primary fill, and a hand-rolled span with its own tone map — reading the same percent in different colours.

WebMeterpackages/ui/src/meter.tsx
Desktopusage_bar::meterapps/desktop/crates/ui/src/usage_bar.rs
iOSAgentUsageTrackapps/ios/ExpUI/Sources/UsageTrack.swift
AndroidUsageTrackapps/android/app/src/main/java/com/exponential/app/ui/components/UsageTrack.kt

2 General components · Feedback

Segmented bar

segmented-bar

EXP-1051: the context window's breakdown — the Meter's track, filled left to right by one slice per layer the run's context_layout named (base, tools, playbook, team prompt, project, task) and the derived conversation, in the contract's tones, clipped at 100% and never rescaled; free space is the bare track. Three hairline ticks mark the compaction floor (50%) and the two usage thresholds (75%, 95%). The legend swatch beside each row reuses the slice's tone, so a square and its slice cannot disagree.

WebSegmentedBarpackages/ui/src/segmented-bar.tsx
Desktoprender_context_windowapps/desktop/crates/ui/src/usage_sheet.rs
iOSSegmentedTrackapps/ios/ExpUI/Sources/SegmentedTrack.swift
AndroidSegmentedTrackapps/android/app/src/main/java/com/exponential/app/ui/components/SegmentedTrack.kt

2 General components · Feedback

Empty state

empty-state

What a PAGE says when it has nothing: the 48px icon disc, one semibold title, one muted sentence that TEACHES the next step rather than restating the emptiness, and an optional actions slot under it — all on a centred column of at most 28rem. Never a bare "No results". Its in-list sibling is `ListEmpty` (same file, its own entry below): one muted line inside a list that filtered down to nothing, where a teaching block would be wrong. The third of them is `EmptyCta` (EXP-962, next entry): the dashed box that STARTS the list, where the empty state itself is the button.

WebEmptyStatepackages/ui/src/empty-state.tsx
Desktopcontrols::empty_stateapps/desktop/crates/ui/src/controls.rs
iOSInboxView.emptyStateapps/ios/Exponential/UI/Inbox/InboxView.swiftevery screen rolls its own private empty state (inbox, reviews, actions, my issues); there is no shared symbol
AndroidEmptyStateapps/android/app/src/main/java/com/exponential/app/ui/components/Scaffolding.kta bare 28dp tinted glyph instead of the 48 disc, and its parameters are message/detail rather than title/description

2 General components · Feedback

Empty call to action

empty-cta

EXP-962: the third empty, and the only one that is a BUTTON. A dashed, full-width box standing exactly where the first row will go — a 16px glyph, one title line, one muted sentence under it, the row wash and full-strength text on hover. Dashed because it is a placeholder for the row it invites; clickable because the shortest path to that row is the box itself. `EmptyState` teaches a PAGE with nothing on it, `ListEmpty` reports a list that filtered down to nothing, and this one STARTS a list: the actions panel's "describe one" nudge is the call site it was cut from.

WebEmptyCtapackages/ui/src/empty-state.tsxEmptyState and ListEmpty are the other two, in the same file
DesktopActionsView::render_nudgeapps/desktop/crates/ui/src/actions_view.rsthe same dashed strip under the actions list, opening the creator run
iOSn/athe creator run needs a device: ActionsListView.emptyState is a read-only page empty instead
Androidn/asame: ActionsScreen's ActionsEmptyState reads, it does not invite — creation lives on web or desktop

2 General components · Feedback

Skeleton

skeleton

A pulsing block standing in for text that is still loading, at the SHAPE of what will arrive — a row's worth of bars, never a spinner in a list. It is a web and desktop affordance only: both natives answer a pending screen with a centred spinner, because a phone list is short enough that a skeleton flashes before it reads.

WebSkeletonpackages/ui/src/skeleton.tsx
Desktopcontrols::skeletonapps/desktop/crates/ui/src/controls.rsradius MD on the theme skeleton fill, breathing 100% to 50% over the web 2s pulse on the standard curve
iOSn/ano skeleton or shimmer anywhere: a loading screen is a centred spinner
Androidn/asame — LoadingState (Scaffolding.kt) centres a spinner instead

2 General components · Feedback

Alert

alert

The inline banner: a message that belongs to the page it interrupts, not a toast that flies past and not a dialog that blocks. Two variants only — the neutral card fill for a notice, and the destructive tint for a failure — and the leading glyph earns its own column only when one is passed. The admin console carried two byte-identical copies of the destructive recipe before this existed.

WebAlert / AlertTitle / AlertDescriptionpackages/ui/src/alert.tsx
Desktopcontrols::alert / alert_titleapps/desktop/crates/ui/src/controls.rsdefault and destructive on the glass tokens, a glyph column only when one is passed; the repository dialog banner is one
iOSn/ano boxed banner: an error renders as a red Text line on DesignTokens.Semantic.red
AndroidGlassNoticeapps/android/app/src/main/java/com/exponential/app/ui/components/GlassNotice.ktthe boxed inline message, but with no title slot and no destructive variant — callers pass the red themselves

2 General components · Feedback

Sub-issue progress ring

progress-ring

EXP-1097: the completion ring that leads the issue detail's Sub-issues band — done of total as an arc over a 20% track, the SAME 16-unit box, 2-unit stroke and track as the context ring (`ringGeometry`), drawn at 14px and painted in the team's COMPLETED status colour. The band reads ring · "Sub-issues" · `done/total` · `+` over flat rows (`lib/issue-relations-view.ts`, fixture-locked ×4).

WebProgressRingpackages/ui/src/progress-ring.tsxthe Sub-issues band in components/issue-relations-card.tsx mounts it
Desktopissue_relations::render_sub_issuesapps/desktop/crates/ui/src/issue_relations.rsEXP-1097: a ProgressCircle on the context ring's geometry, the COMPLETED status colour
iOSProgressRingapps/ios/ExpUI/Sources/ProgressRing.swiftEXP-1097: IssueSubIssuesSection (IssueRelationRows.swift) leads its band with it
AndroidSubIssueProgressRingapps/android/app/src/main/java/com/exponential/app/ui/issue/RelationsSection.ktEXP-1097: SubIssuesSection leads its band with it

2 General components · Feedback

In-list empty line

list-empty

The compact sibling of the empty state: one muted centred line INSIDE a list that filtered down to nothing, exactly the line `CommandEmpty` draws, for the lists that have no Command around them. It is deliberately not the teaching block — a search that matched nothing needs a different QUERY, not a next step, and a 48px icon disc under a search field reads as a page having gone wrong.

WebListEmptypackages/ui/src/empty-state.tsxEmptyState in the same file is the page-sized one
Desktoppickers::empty_picker_rowapps/desktop/crates/ui/src/pickers.rsthe CommandEmpty row every picker shares; controls::empty_state is the page-sized counterpart
iOSText("No emoji found")apps/ios/Exponential/UI/Markdown/EmojiPickerSheet.swiftevery list inlines its own Text: there is no shared line, and DeviceLogins.emptyLine is a private third copy
AndroidChangesEmptyRowapps/android/app/src/main/java/com/exponential/app/ui/work/ChangesFace.ktprivate to one face; the emoji sheet writes its own line, and EmptyState (Scaffolding.kt) is page-sized

2 General components · Feedback · EXP-1031

Toast

toast

The one transient notice, identical on all four clients (packages/domain-contract/fixtures/toast-stack.json, sonner 2.0.7's numbers). One sentence, an optional description, an optional action. The kind (success, error, info, warning) colours the ICON only; the text stays foreground. The card is the opaque glass card fill with the card hairline at radius lg, no shadow, 356 wide on a pointer and full width minus 16 on touch. Placement follows the input: bottom-right on a pointer (web above 600 px, the IDE), 24 from the edges; top-centre on touch (mobile web, iOS, Android), 16 below the status bar, so no sheet, tab bar or keyboard covers it. Newest in front, older ones peek 14 behind and shrink 5% per rank, 3 visible; on a top stack they peek out below the front one and expansion grows downward. Hover or tap expands the stack 14 apart and pauses the 4 s clock. Dismiss = the close glyph, a swipe past 45 (up or sideways on a top stack), or the action. Persistent states stay banners, confirmations stay dialogs.

WebTOAST_CLASSpackages/ui/src/toast.tsx
Desktoptoast::showapps/desktop/crates/ui/src/toast.rsits own ToastLayer per window; gpui_base::ToastManager for the lifecycle, theme::motion for every transition
iOSToastHostapps/ios/ExpUI/Sources/Toast.swift
AndroidToastHostapps/android/app/src/main/java/com/exponential/app/ui/components/Toast.kt

3 Special components · Buttons & chips

Changed files sheet

changes-file-sheet

EXP-895: the phone's file list. A 64-wide column beside a diff leaves neither readable, so below md the Changes face's aside is gone and the tree lives in a bottom sheet hung off the work bar's LEADING slot. The trigger is the bar's own 52px circle carrying the files glyph over the COUNT, which is the whole affordance — a reader has to know how many files a PR touches before deciding to open it. A pick closes the sheet and reports the path; the card list scrolls to it. The sheet is a closed Radix portal at rest, so the specimen is the trigger.

WebChangesFileSheetpackages/ui/src/changes-file-sheet.tsxthe sheet holds the same FileDiffTree the md+ column does; the title is contract.diffUi.changedFilesTitle
Desktopn/ano sheet: the IDE has room for the column, so the file tree is the ReviewFilesNav panel (review_files_nav.rs)
iOSDiffFileListSheetapps/ios/Exponential/UI/Issue/DiffFileListSheet.swiftDiffFilesBarCircle in the same file is the bar trigger
AndroidDiffFileListSheetapps/android/app/src/main/java/com/exponential/app/ui/issue/DiffFileListSheet.ktFileListCircle is the reference the web trigger copies (18px glyph over the count)

3 Special components · Buttons & chips

Open on GitHub

pr-github-button

EXP-916: THE GitHub control of every diff surface — the PR page in a new tab — as one component in two shapes, so the words (the contract's) and the behaviour are written once. `ghost` is the work header's action slot beside Merge; `circle` is a phone work-bar slot, for a run with no issue header to hang it on, and takes its chrome whole from the bar's circle recipe (EXP-1154: the Reviews header's `glass` shape went with that page). Each shape keeps the `data-testid` its surface had before they were merged.

WebPrGithubButtonpackages/ui/src/pr-github-button.tsx
Desktopwork_header::github_buttonapps/desktop/crates/ui/src/work_header.rs
iOSPrChangesFace.githubToolbarButtonapps/ios/Exponential/UI/Issue/PrChangesFace.swiftprivate; WorkScreen.swift holds a second inline copy under the same accessibility id
AndroidGithubHeaderActionapps/android/app/src/main/java/com/exponential/app/ui/work/WorkTopBar.ktthe header arm only — the bar circle has no Android twin

3 Special components · Buttons & chips · EXP-1058

Work header badge

pr-graph-badge

What this work is PART OF, as a quiet icon button beside the `…` (SLOP-16; it replaced EXP-1058's stacked issue chip, which restated the title). The glyph follows the badge SHAPE: a pull request stack (or stack + batch) = `pr-stack`, a batch = `pr-batch`, open blockers = `relation-blocked-by`; a run family alone earns no badge (SLOP-16 r5). A muted mono `+N` beside it counts every other issue on the stack or batch, or the other open blockers (nothing when N is 0). FACE-INDEPENDENT (EXP-1097): the same button on Issue, Run and Changes; absent when there is nothing to say. The tooltip names the shape. Click opens THE "Related work" view, ONE layout on every platform: the platform's standard modal (web: the `dialog` at its default width, which drops to its bottom-sheet arm on a phone) whose body is EXACTLY the relations card's bands (SLOP-16 r5), the foldable band (chevron · icon · title · count, 3 rows then "Show N more"), each only when it has rows, in this order: "Blocked by" over the subject's direct open blockers; "Same pull request" over the batch partners; "Pull request stack" over the stack's OTHER pull requests, bottom-up, each the relations row with the PR glyph · mono `#n` · title · state pill, opening its review page. Nothing else: no mini-graph, no runs, no merge control.

WebPrGraphBadgeapps/web/src/components/pr-graph-badge.tsxicon button per lib/pr-graph.ts badgeShape + badgeChip count; Related work dialog/sheet: the relations card's three bands
Desktoppr_graph::badgeapps/desktop/crates/ui/src/pr_graph.rsicon button per domain pr_graph::badge_shape + badge_chip count; Related work dialog: the relations card's three bands
iOSPrGraphBadgeapps/ios/Exponential/UI/Work/PrGraphBadge.swifticon button per ExpCore PrGraph.badgeShape + badgeChip count; Related work sheet: the relations card's three bands
AndroidPrGraphBadgeapps/android/app/src/main/java/com/exponential/app/ui/work/PrGraphBadge.kticon button per domain PrGraph.badgeShape + badgeChip count; Related work sheet: the relations card's three bands

3 Special components · Lists & rows

File diff tree

file-diff-tree

EXP-916: the file column beside a diff — the review's summary line, a "Filter files" field, then the changed files as a TREE. `diffFileTree` is the shape: directories before files, each group by lower-cased name, a lone-child directory chain compacted into one node (`packages/ui/src`), a directory's counts its subtree's sums. Folders open by default and fold by path; a non-blank filter returns a FLAT list of matching files in input order, because a search result is not a pruned tree. Picking a row only reports the path — the caller scrolls, or closes its sheet.

WebFileDiffTreepackages/ui/src/file-diff-tree.tsx
Desktopdiff_pane::file_treeapps/desktop/crates/ui/src/diff_pane.rs
iOSDiffFileTreeapps/ios/Exponential/UI/Issue/DiffFileTree.swift
AndroidDiffFileTreeapps/android/app/src/main/java/com/exponential/app/ui/issue/DiffFileTree.kt

3 Special components · Lists & rows

Relations card

relations-card

EXP-736: the issue's relations beneath the properties card (web + desktop) or inside the properties sheet (phones). Section header with the Add relation capsule, then one row per link: status glyph, the per-side label, identifier, title, trailing remove. EXP-1097 (direction A, web first): "Sub-issue of [parent]" above the title, a Sub-issues band (progress ring · done/total · +) over flat rows, and one foldable band per remaining side (Blocked by · Blocking · Duplicate of/by · Related, 3 rows then "Show N more") off lib/issue-relations-view.ts; phones keep the bands in the properties sheet under "Relations" + Add.

Relations
blocked byEXP-612ACP: agent client protocol for the steer channel
sub-issue ofEXP-723desktop to web approaching
related toEXP-736issue relation
WebIssueRelationsSection / IssueParentLine / MobileRelationBandsapps/web/src/components/issue-relations-card.tsxEXP-1097: the grouped bands; the model is lib/issue-relations-view.ts
Desktopissue_relations::render_relations_sectionapps/desktop/crates/ui/src/issue_relations.rs
iOSIssueRelationsSectionapps/ios/Exponential/UI/Issue/Sheets/IssueRelationsSection.swiftParent line + Sub-issues on the detail page (IssueRelationRows.swift); side bands in the sheet.
AndroidRelationsSectionapps/android/app/src/main/java/com/exponential/app/ui/issue/RelationsSection.ktSubIssueOfLine + SubIssuesSection on the detail page; side bands in the properties sheet.

3 Special components · Lists & rows

GitHub connection

github-connection

FEED-42: the same block on all four clients. The status sits BEFORE the repo list, every inline action is an sm action pill, the ✕ is always visible and confirms, and accounts + stale marks come from integrations.github.status.

Repositories

Connect a GitHub account or organization first, then add its repositories to share them with the team — everyone can code on a shared repo. Point a board at one to make it the clone target for “Start coding”.

GitHub accounts connected to this team

An installation is per GitHub account or organization. Repositories come from the accounts listed here.

Reconnect GitHub to refresh which repositories you can access from octocat.
No one’s GitHub connection covers installation 42 anymore — reconnecting can’t refresh it.
No GitHub account connected
WebGithubStatusLineapps/web/src/components/team/repositories-section.tsx
DesktopRepositoriesPane::github_status_lineapps/desktop/crates/ui/src/settings/repositories.rs
iOSTeamRepositoriesSectionapps/ios/Exponential/UI/Settings/TeamRepositoriesSection.swiftLives in the native Team settings screen.
AndroidRepositoriesSectionapps/android/app/src/main/java/com/exponential/app/ui/settings/TeamSettingsScreen.ktLives in the native Team settings screen.

3 Special components · Lists & rows

Add-repository picker

repo-picker

FEED-42: tapping a row or a successful Add by name adds at once. The suspended and re-auth banners are independent, and add errors show inline while the picker stays open.

Reconnect GitHub (octocat) to refresh. Repos created or shared with you since your last connect won’t appear until you do.
acme/web
acme/billing
octocat/dotfiles
WebGithubRepoPickerapps/web/src/components/github-repo-picker.tsx
Desktopadd_repository_dialog::footerapps/desktop/crates/ui/src/settings/add_repository_dialog.rs
iOSGithubRepoPickerapps/ios/Exponential/UI/Settings/GithubRepoPicker.swiftA sheet; a plan-limit add shows no web upgrade pointer (EXP-216).
AndroidGithubRepoPickerSheetapps/android/app/src/main/java/com/exponential/app/ui/onboarding/GithubRepoPickerSheet.ktA sheet; a plan-limit add shows no web upgrade pointer (EXP-216).

3 Special components · Lists & rows

Session results

session-results

EXP-879: the screenshots a run published with `exponential_sessions_results`, read off the synced jsonb — EXP-933: and each topic's GFM report text (`text` entries), drawn above its tiles by the app's markdown renderer (`renderText`; plain pre-wrapped prose without one), so a text-only `Summary` topic reads as the run's report. One group band per topic over a wrapping strip of tiles, so an iOS, an Android and a web shot of ONE screen read as one row — which only works because every tile is the same height and takes its width from the probed aspect (a 4:3 desktop frame stands in when the upload could not be measured). On a narrow column the whole page scales down by ONE factor, the widest tile's overflow, rather than letting a row clip or each row pick its own size. Tapping a tile opens the shared lightbox. The tile URL is passed in: this package owns the tiles, the app owns the route. EXP-1154: the face reads as the GUIDE (the Results guide entry): Summary leads unbanded, every other band wears a `01 / 04` caption and lists the files its topic touched.

WebSessionResultsViewpackages/ui/src/session-results-view.tsxthe pure rules (grouping, tile width, the fitting factor) are session-results.ts, mirrored byte for byte ×4
Desktopsession_results::renderapps/desktop/crates/ui/src/session_results.rs
iOSSessionResultsFaceapps/ios/Exponential/UI/Work/SessionResultsFace.swift
AndroidResultsFaceapps/android/app/src/main/java/com/exponential/app/ui/work/ResultsFace.ktResultTile + ResultPreviewDialog sit in the same file

3 Special components · Lists & rows · EXP-996

Session tree

session-tree

Runs nested under their parent, resumes collapsed. ONE selector over the synced coding_sessions rows (`sessionTree`) that every sessions list draws: a resume succession is ONE row keyed by its newest, a `sessions_start` child nests under its parent's succession, an orphan whose parent is gone sits at top level. Top-level rows sort by last activity, newest first; children keep creation order; folding a parent takes its children with it. EXP-1208: every row ×4 LEADS with the run mark (the agent's brand mark, Claude's spark while it works, a state badge when parked, dimmed once ended; never a dot) at the row's base inset 12 + 14 per level, and a parent's fold chevron FOLLOWS the mark, so a parent lines up exactly with a standalone row and its child's connector elbow ends at the child's mark.

WebSessionTree / sessionTreeapps/web/src/components/session-tree.tsxthe rule is lib/sessions/session-tree.ts; the rows are components/session-list-rows.tsx; drawn in every sessions list
Desktopdomain::session_tree::session_treeapps/desktop/crates/domain/src/session_tree.rsdrawn by sidebar.rs (Running) and sessions_section.rs; the rows are run_rows.rs
iOSSessionTree.sessionTreeapps/ios/ExpCore/Sources/Domain/SessionTree.swiftdrawn by UI/Agent/AgentSessionsList.swift; the rows are UI/Session/RunningSessionRow.swift + ExpUI EndedRunRow
AndroidSessionTree.sessionTreeapps/android/app/src/main/java/com/exponential/app/domain/SessionTree.ktdrawn by ui/agent/AgentSessionsList.kt; the rows are ui/session/RunningSessionRow.kt + ui/components/EndedRunRow.kt

3 Special components · Lists & rows · EXP-1154

Results guide

results-guide

The Results face as the Guide, one shape ×4 (fixture `session-results.json` `files` + `guide`). The `Summary` topic leads as a plain paragraph at the reading width, no band, no number; every other topic is the group band with a muted tabular `01 / 04` caption in its leading slot (never uppercase), its 2 or 3 sentences, then the FILES it touched as flat hairline rows (the dimmed-directory path, `+N −M` when the loaded diff has the exact path; a tap opens the Changes face on that file), then its tiles and the `Earlier` fold. The same report is the pull request's body on GitHub (text only). An issue with an open PR and no report shows the GitHub PR body as one unnumbered band.

WebSessionResultsView / GuideSectionHeader / GuideFileListpackages/ui/src/session-results-view.tsxpure rules guideSections / guideSectionCaption / guideFileRows in session-results.ts
Desktopsession_results::renderapps/desktop/crates/ui/src/session_results.rsrules in crates/domain/src/session_results.rs
iOSSessionResultsFaceapps/ios/Exponential/UI/Work/SessionResultsFace.swiftrules in ExpCore/Sources/Domain/SessionResults.swift
AndroidResultsFaceapps/android/app/src/main/java/com/exponential/app/ui/work/ResultsFace.ktrules in domain/SessionResults.kt

3 Special components · Lists & rows · EXP-1183

MCP App views

mcp-app-views

Exponential inside an MCP Apps host. `exponential_issues_show` mounts the issue list (status group bands over the board's flat rows; a row opens the issue in place: pills, GFM description, comments), `exponential_sessions_list` the run list (Running / In review / Ended bands; a row opens the run), `exponential_sessions_get` the run's report as the Results Guide, `exponential_notifications_list` the inbox (a row opens its issue), `exponential_devices_list` the Devices page (an own online machine offers Sign in on a dead login and Add account, both through `exponential_devices_account_login`). One self-contained document served as `ui://exponential/*`, themed by the host's light/dark.

WebIssueListView / IssueDetailView / RunsListView / RunView / InboxView / DevicesViewpackages/mcp-apps/src/issue-list-view.tsxserved by apps/web/src/lib/mcp/apps.ts
Desktopn/aMCP Apps render inside the third-party host, never in a client.
iOSn/aMCP Apps render inside the third-party host, never in a client.
Androidn/aMCP Apps render inside the third-party host, never in a client.

3 Special components · Lists & rows · EXP-1175

Run status row

run-status-row

The Run face opens as the THREAD, not the transcript: one status row over the run's published results. The row = the run mark as the spinner, the caption in its tone (`Building on <device> · <elapsed>` while it works, `Needs input` amber, `Ready for review` emerald, `Done` sky, `Paused`, `Ended on <device> · <elapsed>`), the last tool line muted under it (live only) and `Show work` on the right. Show work swaps the thread for the full transcript IN PLACE (same scroller, strips and composer) and is remembered per user. Pending questions and plans never hide. Label + state only (fixture `run-row.json`).

WebRunStatusRowpackages/ui/src/run-status-row.tsxrules: lib/work-faces.ts runRowCaption + lib/agent-feed.ts lastToolLine, fixture run-row.json
Desktopsteer_viewer::render_status_rowapps/desktop/crates/ui/src/steer_viewer.rsstyleguide/entries/run_status_row.rs
iOSRunStatusRowapps/ios/Exponential/UI/Session/RunStatusRow.swift
AndroidRunStatusRowapps/android/app/src/main/java/com/exponential/app/ui/session/RunStatusRow.kt

3 Special components · Lists & rows · EXP-1175

Results thread

session-thread

The thread under the status row: the run's published results in PUBLISH order (`sessionThread`): a topic's first text under its muted caption, a picture where it was published, and the Summary text LAST as the agent's reply. Pending plan and question cards follow it, so a question never hides. Show work swaps the whole thread for the transcript in place (fixture `session-results.json` `thread`).

WebSessionThreadViewpackages/ui/src/session-thread-view.tsxrule: session-results.ts sessionThread, fixture session-results.json thread
Desktopsteer_viewer::render_threadapps/desktop/crates/ui/src/steer_viewer.rs
iOSAgentSessionView.threadListapps/ios/Exponential/UI/Session/AgentSessionView.swift
AndroidRunThreadapps/android/app/src/main/java/com/exponential/app/ui/session/AgentSessionScreen.kt

3 Special components · Surfaces

Session bar

session-bar

The bottom strip of coding tabs (EXP-769). It sits OUTSIDE the content card, on the bare page ground below it, the mirror of the desktop's head toolbar above (EXP-771): the card stops 6px short and the band takes the last 36 down to the window bottom, with no fill and no border of its own and its chips inset 8. Give it a fill and the ground reads as a second card. It holds the rich tabs of the user's running sessions and, on the desktop, the open terminals — a session tab leads with its 6px liveness dot and carries the issue's mono identifier, the title and a muted " · machine" caption; a terminal tab leads with the terminal glyph and wears its exit code as a badge. Right after the last tab sit two ghost 24px glyph buttons: Chat (a promptless chat run on the default agent) and add (a plain terminal, desktop only). Nothing else: no header, no collapse, no label when empty — the strip is always there so Chat is always one click away. Selecting a tab opens that session or terminal FULLSCREEN in the content area; the web navigates to the session route, the desktop shows the screen. The × on a live session kills it (confirmed), on an ended one closes the transcript tab, on a terminal closes the terminal.

Fix the merge queueAPP-14zsh1bun run typecheckexit 1
Webn/aEXP-818: no bottom band on the web — live runs are the sidebar's Running section (EXP-923), past ones the Recent panel.
Desktopsession_bar::SessionBarapps/desktop/crates/ui/src/session_bar.rsthe tabs are ScreensPanel::render_session_bar_tabs; terminals are Screen::Terminal center screens
iOSn/ano session bar: sessions open full-screen from Agents
Androidn/ano session bar: sessions open full-screen from Agents

3 Special components · Surfaces

Entity preview card

entity-preview-card

What an entity chip shows on hover (pointer) or tap (a bottom sheet on phones): the issue preview's chrome, opened to every kind (EXP-920). One ladder — glyph and EYEBROW (the kind noun or an identifier), the title, a subtitle, an optional multi-line body that folds behind "Show more", a wrapping row of fact pills, then ROWS (a board's open issues, a list chip's members, each a target) and a muted "+N more" when the answer carried more than the card lists. Everything arrives already resolved from the client's own synced rows — a preview never asks the server — and a row the viewer cannot see renders no card at all.

WebEntityPreviewCardpackages/ui/src/entity-preview-card.tsxThe app's components/entity-preview/entity-preview-card.tsx picks the content per kind.
Desktopentity_preview::cardapps/desktop/crates/ui/src/entity_preview.rs
iOSEntityPreviewCardapps/ios/ExpUI/Sources/EntityPreviewCard.swift
AndroidEntityPreviewCardapps/android/app/src/main/java/com/exponential/app/ui/components/EntityPreviewCard.kt

3 Special components · Surfaces

App shell

app-shell

The CUTOUT (EXP-723). The window is the page gradient; the navigation column sits directly on it with no fill of its own; the content is a card inset 10 on the sides and top, and 6 at the bottom while a session band renders (10 otherwise) — radius 12, a card hairline, the panel wash, overflow hidden. Chrome that is not content sits on the bare ground AROUND the card, symmetrically (EXP-771): the desktop's title strip above it and the session band below on both clients, each 36 tall with no fill and no border, their chips inset 8, and the band running to the window bottom. The wash is translucent on purpose: the ground darkens down the page and a solid fill would drift away from it. Phones drop the card entirely and run full-bleed under the tab bar.

Mobile app
zsh1Fix the merge queueAPP-14
WebmainPanelClassapps/web/src/components/team/app-shell.ts
DesktopShell::renderapps/desktop/crates/ui/src/shell.rscutout panel painted by Shell::render, FILL_PANEL over the content ramp
iOSn/aphones are full-bleed under the tab bar; no cutout
Androidn/aphones are full-bleed under the tab bar; no cutout

3 Special components · Surfaces

Settings page header

page-header

Every settings page on web and desktop opens identically (EXP-771): the title Settings at 2xl bold, the subtitle "Manage {team name} and your account" at sm muted, then a hairline divider. All three ride a centred column capped at 56rem (896px at a 16px root; the desktop pins 896 outright) with 1.5rem padding, while the SCROLL region is the full width of the pane, so the scrollbar rides the viewport edge instead of the text column. The nav beside it lists every page: web and desktop both carry Widget and MCP servers under a Features group.

Settings
Manage Mobile Ltd and your account
Team nameMobile Ltd
End runs on merge
WebSettingsLayoutapps/web/src/routes/t/$teamSlug/settings/route.tsx
Desktopsettings::detail_columnapps/desktop/crates/ui/src/settings/mod.rsthe column and the header the SettingsView panes render into
iOSn/athe native settings root is a grouped list, no page title band
Androidn/athe native settings root is a grouped list, no page title band

3 Special components · Surfaces

File diff card

file-diff-card

EXP-916: the ONE per-file unit every diff surface is made of — the review page, a Changes face and the transcript's edited-files card all stack THIS. A sticky glassy header (`status letter · dimmed dir / name · +a −d · chevron`) over the unified four-column body; the collapse threshold, the "Show N more lines" step and its wording are `contract.diffUi`, so nobody re-derives them. Two extra header states carry a live edit run: `pending` (the call is still going — no counts, an inert chevron, no body) and `failed` (rose, a trailing "failed", no body). `flush` drops the outer box so a card can be a ROW of a parent that divides its own children.

WebFileDiffCardpackages/ui/src/file-diff-card.tsx
Desktopdiff::render_file_cardapps/desktop/crates/ui/src/diff.rs
iOSDiffFileCardapps/ios/Exponential/UI/Issue/DiffFileCard.swift
AndroidDiffFileCardapps/android/app/src/main/java/com/exponential/app/ui/issue/DiffFileCard.kt

3 Special components · Surfaces

Edited files card

edited-files-card

EXP-916: what a transcript draws for a run of consecutive file edits — `N files edited` over a flush stack of the very same file card, hairline-divided. The grouping rule and the rows are the contract's (`@exp/domain-contract/edit-card`: one row per PATH, a file touched twice folds into one with summed counts, a call with no patch yet is a `pending`/`failed` stub). Everything starts collapsed except the LIVE row, which opens itself so the reader watches the edit land and folds back when the card settles; past `cardPreviewFiles` the rest sit behind "N more". A tap toggles a row in place — a card never navigates anywhere.

WebEditedFilesCardpackages/ui/src/edited-files-card.tsx
Desktopsession_extras::render_edit_cardapps/desktop/crates/ui/src/session_extras.rs
iOSEditedFilesCardapps/ios/Exponential/UI/Session/EditedFilesCard.swift
AndroidEditedFilesCardapps/android/app/src/main/java/com/exponential/app/ui/session/EditedFilesCard.kt

3 Special components · Surfaces

Comment card

comment-card

One comment in the activity feed: the avatar rides the timeline gutter, everything else lives in a radius-16 card of card fill under a card hairline. The header is the author at body size and medium weight, then a muted caption carrying the relative time and, when it applies, "edited", "via MCP" for a comment an agent posted, "reporter" for the widget reporter's words (signed with the name they gave, else Anonymous visitor) or "to reporter" for a member's reply that was emailed to them. Images attached to the comment are LARGE tiles stacked under the body — full width, capped at 480 tall, radius 12, hairline, reserving their probed aspect ratio — and any other file stays a read-only pill. The card is the thread: its replies sit under the body behind one hairline, each with a 20px avatar and the same header, and every top-level card closes with a muted "Leave a reply…" row — on web and desktop the composer opens in its place, on mobile it hands the docked composer a "Replying to" target.

AL
Ada Lovelace2 days ago · edited
Pushed the fix. The strip only reserved height once the image had decoded.
trace.txt
JK
Jonas Klein1 day ago · via MCP
Confirmed on the device farm, cold start is under target now.
EF
Emma Fischer3 hours ago · reporter
Thanks, the update fixed it on my iPad too.
Leave a reply…
WebRegularCommentRowapps/web/src/components/comment-rows/regular.tsx
Desktopcomments::comment_rowapps/desktop/crates/ui/src/comments.rs
iOSRegularCommentRowapps/ios/Exponential/UI/Issue/CommentThreadView.swift
AndroidRegularCommentRowapps/android/app/src/main/java/com/exponential/app/ui/issue/RegularCommentRow.kt

3 Special components · Surfaces

Composer

composer

ONE composer for comments, steering and reporter replies: a radius-16 card of card fill under a card hairline, holding an optional attachment strip, a borderless 36-min field and a tool row of 24px ghost glyph buttons with a right-aligned submit whose glyph is the primary tint. EXP-877's `inline` arm is the steer card — the round submit rides the field's own row instead of a tool row under it. The opaque variant swaps to the opaque card fill and the strong stroke — it floats over a feed on mobile, and an alpha fill there shows the conversation through it. EXP-961 moved it into @exp/ui: the card owns CHROME AND LAYOUT only, and every caller keeps its own field, upload and send.

WebComposer / ComposerTool / ComposerSubmitpackages/ui/src/composer.tsx
Desktopcomposer::glass_composerapps/desktop/crates/ui/src/composer.rs
iOSGlassComposerapps/ios/ExpUI/Sources/GlassComposer.swift
AndroidGlassComposerapps/android/app/src/main/java/com/exponential/app/ui/components/GlassComposer.kt

3 Special components · Surfaces

Markdown blocks

markdown

The chat-sized set the steer feed is built from. Narration is bare text at 90% behind a 12px glyph at 50% — no bubble, because a wall of them is unreadable. The person's turn IS a bubble: radius 12, active fill, strong hairline. Plan and question share ONE neutral radius-16 card; only the header line is tinted, primary for a plan and yellow for a question. EXP-1191, the web card ×4: the 14px glyph LEADS the card beside the question (never on a line of its own), and the heading, the question and the options stack in the column beside it, so the options indent under the text; each option row ends in its number key as a small square chip on the RIGHT. The agent's task list is the LAST line above the composer (under the queued messages), unboxed, with its own segmented progress mark (`TaskListProgress`) before `done/total`, so it never reads as a message or the composer. Its options are full-width rows: the promoted one wears the primary fill, a pick the glass active fill, never blue; a free-text row opens the composer card inline under itself. A tool line is a 12px label with a truncated mono detail at 50%, and any long block clamps at 160 behind Show more. Inline code is tinted in chat feeds only — the issue and comment renderers keep the neutral chip.

Reading the merge queue, then the applyPrMergeState webhook that feeds it.
Readapps/web/src/lib/trpc/coding-sessions.ts
Rebase onto master first, then open the PR.
Plan ready
Move the merge-state fan-out into applyPrMergeState, end every live session on the merged branch, and leave the run that merged its own PR alone. Then re-point the webhook and the poller at the same helper so the two paths cannot drift again, and cover both with one test that merges a batch PR and asserts every linked issue lands on the team's merge target, on the webhook path and the polling one.
Show more
Needs input
Should a batch PR merge close every linked issue, or only the ones whose branch matches?
WebQuestionCard / NarrationBubbleapps/web/src/components/agent-session.tsx
DesktopSteerSessionView::render_item / render_askapps/desktop/crates/ui/src/steer_viewer.rs
iOSQuestionCardapps/ios/Exponential/UI/Session/AgentSessionView.swift
AndroidQuestionCardapps/android/app/src/main/java/com/exponential/app/ui/session/AgentSessionScreen.kt

3 Special components · Surfaces

Bulk action bar

bulk-bar

The selection's own bar: the tab bar's opaque card at radius XL3, padding 10x8, holding the clear cross, the count, one ghost button per property, the accent Start coding pill and a destructive trash. On a phone it REPLACES the tab bar and the labels drop away.

3StatusPriorityAssigneeLabelsDelete
3
WebBulkActionBarapps/web/src/components/bulk-action-bar.tsx
Desktoprender_bulk_barapps/desktop/crates/ui/src/issue_list.rs
iOSselectionBarapps/ios/Exponential/UI/Issue/IssueListView.swift
AndroidSelectionBarapps/android/app/src/main/java/com/exponential/app/ui/issue/IssueListScreen.kt

3 Special components · Surfaces

Auth form shell

auth-shell

The frame every signed-out page wears (EXP-1176: no card, no wordmark): the 56px mark over the title (`BrandHeading`), a description line ONLY when it carries state (the OTP step's "We sent a 6-digit code to …"), the flow's own fields on the bare page gradient, then a footer line under them and the Privacy · Terms pair below. Login, signup, the OTP step, the invite page, the device-code page and the MCP consent screen all open in it, which is why the column is capped at 24rem — a sign-in form that spans a desktop viewport reads as a settings page. The onboarding welcome step wears the same head over two standard buttons.

WebAuthFormShellpackages/ui/src/auth-form-shell.tsx
DesktopLoginView::renderapps/desktop/crates/ui/src/login.rsthe brand head + bare form column in LoginView::render mirror this shell, inlined in the screen, not a symbol of its own
iOSLoginViewapps/ios/Exponential/UI/Auth/LoginView.swifta plain ScrollView/VStack: no shared shell, so each auth flow re-states its own header and footer
AndroidLoginScreenapps/android/app/src/main/java/com/exponential/app/ui/auth/LoginScreen.ktsame — the card, the brand row and the legal line are built inline in the screen

3 Special components · Surfaces

Work bar

work-bar

EXP-893: the phone's ONE floating bottom bar, `[circle] [capsule] [circle]` in the floating-glass recipe, shared by every face of the Work screen — the issue (Properties · Comment · Start), the run (usage ring · composer · Start once the run ended for good), the changes (the file sheet + the white Merge capsule, a centred cluster) and the results (the Merge capsule alone). EXP-1150: the face SWITCHER circle is gone — the faces are the segmented tabs INSIDE the header band (the Work header's own `WorkFaceToggle`, iOS/Android `GlassSegmentedControl`), and the body swipes between them. EXP-1154: the ONE merge is the SOLID white capsule on this bar again on all four faces: in the cluster on Changes and Results, floating centred 10px above the composer bar on Issue and Run (hidden while the composer is open). EXP-916 locked the geometry to Android's: a 20px screen inset, 10px between the slots, 52px circles with 20px glyphs, a capsule padded 18px. Expanding the composer replaces the left circle and the capsule while the trailing circle stays MOUNTED. EXP-1162: the last rows pass BEHIND the bar through the bottom edge layer (contract `detail-chrome.json`: a blurred scrim fading upwards from the screen edge to 32px above the bar) instead of ending hard under the slots. The real bar is `fixed … md:hidden`, so the specimen is its SLOTS in a row.

WebMobileWorkBar / MobileWorkCapsulepackages/ui/src/mobile-work-bar.tsxits circles are FabButtons now; the clearance constant is the faces' scroll padding
Desktopn/ano floating phone bar: the IDE's bottom edge is the terminal session bar
iOSFloatingBottomBar / FloatingBarCircle / FloatingBarCapsuleapps/ios/ExpUI/Sources/FloatingBottomBar.swiftFloatingBarCluster is the Changes/Results layout, where the slots hug their content
AndroidFloatingBottomBar / BarCircle / BarCapsuleapps/android/app/src/main/java/com/exponential/app/ui/components/FloatingBottomBar.ktthe reference geometry the other three phones are locked to (EXP-916)
Still drawn by hand
  • apps/web/src/components/issue-changes-face.tsxthe white MergeCapsule wears MOBILE_WORK_CAPSULE_CLASS on a SessionMergePill, not MobileWorkCapsule

3 Special components · Surfaces

Work header

work-header

EXP-877: the ONE header the issue route and the session route share, on the 896px reading column every face uses (body, transcript, diff). EXP-1162 (contract `detail-chrome.json`, ×4): it is a compact BAR and the large title is a ROW of the issue face's scrolling body. At rest the bar floats over the scroller with its right cluster alone, on the title's own line; once the title row scrolled away it BREAKS (a threshold, 160ms, rising 4px) into the collapsed title — the mono identifier over the title on one truncated line. A face with no title row of its own (Run, diff, Results) is always collapsed, with the properties tray under the bar. No hairline closes it: content scrolls under an edge layer (page ground at 72% over an 8px blur) whose last 24px fade to nothing; the phone's header band and floating bottom bar (32px) wear the same edges.

WebWorkHeader / CollapsedTitlepackages/ui/src/work-header.tsxdetail-chrome.tsx holds CollapsedTitle and the DETAIL_EDGE_* layers the phone header and bar also draw
Desktopwork_header::render_work_headerapps/desktop/crates/ui/src/work_header.rsgpui cannot blur in-window content: the edges are the scrim fade alone (surface.rs)
iOSWorkTitleapps/ios/Exponential/UI/Work/WorkTitle.swiftthe system nav bar's .toolbar (WorkScreen.swift) over workHeaderBand; properties stay a row of the issue face
AndroidWorkTopBarapps/android/app/src/main/java/com/exponential/app/ui/work/WorkTopBar.ktan M3 TopAppBar over the face tabs; the blur needs API 31, below it the scrim fade alone

3 Special components · Surfaces

Media lightbox

lightbox

EXP-316/EXP-824: the shared viewer a picture, a clip or an audio attachment opens in — the description editor's image node, the storage table's filename, a results tile. The dialog is borderless and HUGS its media instead of spanning the viewport, and it is the one dialog that stays a full-screen page below sm rather than dropping to a sheet: a lightbox wants the whole screen. The video arm mounts only while open, so autoplay fires on every open and the stream stops the moment it unmounts. The dialog is a portal, so the island is its BODY (`PreviewMedia`) — the same switch, without the frame.

WebImagePreviewDialog / PreviewMediapackages/ui/src/image-preview-dialog.tsxPreviewMedia is the non-portal body, split out so a static host can render the specimen
Desktopimage_preview::open_image_previewapps/desktop/crates/ui/src/image_preview.rsEXP-284: a real OS window sized to the probed aspect; open_media_preview is the clip arm
iOS.quickLookPreview($previewURL)apps/ios/Exponential/UI/Components/AttachmentStrips.swiftthe system QuickLook sheet, not our chrome: no shared component and no in-app audio caption
AndroidResultPreviewDialogapps/android/app/src/main/java/com/exponential/app/ui/work/ResultsFace.ktprivate and results-only; an issue attachment hands off to another app (ui/issue/AttachmentOpen.kt)

3 Special components · Surfaces · EXP-1019

Composer dialog

composer-dialog

THE launcher, opened over wherever the play button was pressed instead of navigating away from it. The SUBJECT leads: the contract's verb (Run for an action, Implement for issues, ×4) followed by the removable subject chips, set as the biggest thing in the dialog — a prefilled action used to be a small badge inside a prompt box, so nobody realised the thing they picked was already loaded and one press away. The field under it is the SECONDARY half and says so ("Additional instructions (optional)…", or an action's own promptPlaceholder); under that the three tools (issues, actions, image) and the round send, then the muted options line: device, account, model, the Plan switch and ⋯ for the rest. A subject-LESS chat still opens the Agent page, where a chat belongs, and the same composer draws both — there is no second launcher to drift.

An action — one chip, and the send is the only thing left to do.

RunFix merge conflicts
Additional instructions (optional)…

Two issues — one batch run on one branch, one combined PR.

ImplementEXP-1019EXP-1039
Additional instructions (optional)…
WebLaunchDialogHost / LaunchComposer / LaunchHeadlineapps/web/src/components/launch-dialog/launch-dialog.tsxthe card is launch-composer.tsx, the headline launch-dialog/launch-headline.tsx
Desktopcomposer_dialog::openapps/desktop/crates/ui/src/composer_dialog.rsthe composer is ChatScreenView in its Dialog presentation (chat_screen.rs): one launcher, two presentations
iOSAgentComposerHeadlineapps/ios/Exponential/UI/Agent/AgentComposerCard.swiftthe headline and the secondary field are there, but the composer is a pushed page, not a dialog
AndroidAgentComposerHeadlineapps/android/app/src/main/java/com/exponential/app/ui/agent/AgentComposer.ktsame: the headline leads the Agent screen, and the phone presents the launcher as a screen

3 Special components · Surfaces · EXP-1074

Issue context menu

issue-context-menu

The menu a right-click (or a phone's long-press) opens on ANY issue: a board row, the sidebar list, a reviews row, an issue chip. One host per team layout on web — an element opts in with its issue id, the menu resolves the issue live — and one layout: the header band, Open / Mark as done / Copy issue ID (Select on a phone, Unmark duplicate on a duplicate), a divider, then the field submenus — Status, Assignee, Priority, Labels, Estimate (teams that estimate), Set due date, Move to board (teams with several), Add relation — and Delete issue in red, with no divider above it. Each submenu shows the current value beside its label; the label never wraps, the value truncates.

WebISSUE_MENU_LAYOUTpackages/ui/src/issue-menu.tsdrawn live by apps/web/src/components/issue-context-menu (its test locks the order to this layout)
Desktopbuild_row_context_menuapps/desktop/crates/ui/src/issue_list.rsthe same rows in the same order, Estimate included (EXP-1077); Delete confirms in a dialog
iOSn/ano row menu by decision (EXP-698 r5): status and priority sit on the row, a selection does the rest
Androidn/aa long-press selects (EXP-239); the My issues long-press sheet keeps Mark done / Move to backlog

3 Special components · Surfaces · EXP-1020

Device settings

device-settings

One layout on all four clients (EXP-1020). Top to bottom: the identity row, the default-device toggle, sharing (server machines), then the agent-defaults card — agent tabs, model, subagent model (claude only), effort, ultracode, plan mode — and nothing after it. Update comes last but one; "Remove device" is a plain row, not a section of its own. No worktrees: a machine's worktrees are the IDE's local Settings → Worktrees. A headline only appears where something else shares the page, which is why the agent card has none.

WebDeviceSettingsDialogapps/web/src/components/device-settings-dialog.tsx
DesktopDeviceSettingsViewapps/desktop/crates/ui/src/device_settings.rs
iOSDeviceSettingsSheetapps/ios/Exponential/UI/Session/DeviceSettingsSheet.swift
AndroidDeviceSettingsSheetapps/android/app/src/main/java/com/exponential/app/ui/session/DeviceSettingsSheet.kt

3 Special components · Surfaces · EXP-980

Blocked-start dialog

blocked-start-dialog

What starting a BLOCKED issue asks first (EXP-980, reworked by SLOP-3). The sentence names the direct open blockers as issue chips flowing INLINE in its text, the last one glued to the suffix's "." so a wrap never opens a line with it (EXP-1167; desktop and iOS print one text run, Android stacks the prefix, the chip row and the suffix without its "." in a column); under it THE blocks mini-graph draws the whole transitive chain, top = the first blockers, bottom = the subject in the accent ring. It is the shared prompt card (EXP-1215): the sentence is its body, the graph its content slot. Three answers in ONE row of 32px pills, in this order: Cancel, "Start anyway" (a plain pill), "Stacked PR" (the primary pill). "Stacked PR" builds the dependency LINE bottom-up and is never hidden: over a line of two or more issues the plan note under the graph says which one starts first; while it cannot stack (a batch, a cycle, a fork, another repository, more than 5 issues, a member still running) the button is DISABLED and the reason's note takes the plan note's place, and the sentence drops its stacked half. A batch asks with its own title and one line of body ("Some of these issues are blocked"). The copy, the reasons and their order are the contract's (`blocked-start.json`, byte-locked ×4). Phones keep the three answers; iOS hosts the graph in a fitted sheet instead of an alert.

WebBlockedStartDialogapps/web/src/components/blocked-start-dialog.tsxcopy and the stack plan are lib/blocked-start.ts; the graph is TeamIssueGraph
DesktopChatScreenView::prompt_blocked_start / render_blocked_panelapps/desktop/crates/ui/src/chat_screen.rsa native alert on the page; inside the composer dialog the same question takes over its body
iOSBlockedStartSheetapps/ios/Exponential/UI/Agent/BlockedStartSheet.swifta fitted sheet rather than an alert, because it hosts the graph
AndroidBlockedStartDialogapps/android/app/src/main/java/com/exponential/app/ui/agent/AgentScreen.kt

3 Special components · Surfaces · EXP-1145

Stack merge dialog

stack-merge-choice-dialog

What a plain Merge asks when its pull request is a member of an OPEN stack (EXP-1145), instead of the one-line confirm: the issue header, the Changes face, the review page, the run view and, since SLOP-3, every Reviews row. The title says so; the body lists the chain bottom to top joined by arrows, the pressed one marked "(this one)" and a batch pull request named with its count (`EXP-874 +2`), then one sentence per answer saying exactly what lands. Three answers, in this order: "Cancel", "Merge this pull request" (a plain pill: the bottom member merges alone, any other one lands itself and everything below, and what sits above is retargeted onto the base branch and stays open), "Merge stack" (the primary pill, the whole open chain, bottom-up). It is the shared prompt card (EXP-1215): one row of 32px pills, no ✕. The primary LEADS with the merge glyph on every client that draws button glyphs: web and the desktop alert (EXP-1167 settled it: the desktop alert was label-only). While a merge is in flight all three are disabled and the primary's glyph spins. A pull request with no other OPEN member in its chain never sees this dialog. The decision and every word are ONE pure function ×4 (`stack-merge-choice.json`).

WebStackMergeChoiceDialogapps/web/src/components/stack-merge-choice-dialog.tsxthe copy is lib/pr-stack.ts stackMergeChoice; SessionMergeButton and the Reviews rows open it
Desktoppr_merge::ask_stack_mergeapps/desktop/crates/ui/src/pr_merge.rsa native alert whose primary wears the merge glyph (AlertSpec::ok_icon); the copy is domain pr_stack::stack_merge_choice
iOSWorkMergePillapps/ios/Exponential/UI/Work/WorkMergePill.swiftthe shared GlassAlert over PrStack.stackMergeChoice; ReviewsView asks the same way
AndroidStackMergeDialogapps/android/app/src/main/java/com/exponential/app/ui/issue/ChangesScreen.ktthe shared GlassAlert over PrStack.stackMergeChoice

3 Special components · Surfaces · EXP-1212

Draft leave dialog

draft-leave-dialog

What leaving a New issue draft WITH content asks (EXP-1212; a title, a description or an attachment is content, and a reopened draft whose files are not known yet counts as content). Each prompt is ONE question (no body line, no ✕; Esc and the scrim dismiss) over ONE row of the app's 32px `Pill` capsules, the same capsules the natives' alert card draws. Its close button first asks "Discard this draft and its files?": Cancel and "Discard" (the plain pill with a destructive label and tinted border, no fill). Every other way off the page (back, a nav entry, another screen, closing the IDE tab) is HELD and asks "Save this issue as a draft?", Thunderbird's save prompt: "Discard" set apart on the leading edge (quiet destructive text, no second confirm), then "Save draft" (the plain pill: save, then continue; a failed save stays on the page with the save error) and the default "Create issue" (the primary pill, trailing, initial focus and Enter; the held navigation continues instead of opening the new issue; disabled without a title, then "Save draft" takes the focus). Dismissing stays. Nothing is asked while a Create is in flight, and a draft with no content leaves silently. A mode that never writes a draft row (a sub-issue or share compose on phones) offers Discard and Create issue only. The copy is the contract's (`issue-draft.json`, byte-locked ×4).

WebIssueDraftPageapps/web/src/components/issue-draft-page.tsxthe shared Prompt; a router useBlocker holds the navigation; the copy is lib/issue-draft-page.ts ISSUE_DRAFT_COPY
Desktopissue_draft_screen::prompt_leaveapps/desktop/crates/ui/src/issue_draft_screen.rsnative alerts (AlertSpec); the copy is domain issue_draft
iOSIssueDraftPageViewapps/ios/Exponential/UI/Issue/IssueDraftPageView.swiftExpUI GlassAlert (Pill row); IssueDraftLeaveGuard holds the navigation
AndroidIssueDraftScreenapps/android/app/src/main/java/com/exponential/app/ui/issue/IssueDraftScreen.ktGlassAlert (Pill row); navigation/LeaveGuard holds the navigation

3 Special components · Surfaces · SLOP-7

Ready to code? checklist

readiness-checklist

What Start coding opens while a run cannot start yet (EXP-1121/SLOP-7), one layout ×4. Three steps in a fixed order — GitHub connected, Repository connected, Device online — under a three-slice progress strip. A step is MET (green tick, muted title, its detail on the right), CURRENT (the first unmet one: amber ring, amber wash, its fixes inline, the first one primary) or PENDING (dashed grey ring, no fixes until it is current). The model and every word are the fixture-locked coding-readiness.json; this specimen is its "repo missing" case.

WebReadinessRowpackages/ui/src/readiness-checklist.tsx
Desktopcoding_readiness::render_popoverapps/desktop/crates/ui/src/coding_readiness.rs
iOSCodingReadinessSheetapps/ios/Exponential/UI/Issue/CodingReadinessSheet.swift
AndroidCodingReadinessSheetapps/android/app/src/main/java/com/exponential/app/ui/issue/CodingReadinessSheet.kt

3 Special components · Surfaces · EXP-1196

Device readiness

device-readiness

One block ×5 from the device's doctor report (device-doctor.json): a band per group (Required, Coding agents, Computer use; the tag trailing), one flat row per item with its state glyph, label, the device's detail and at most one pill. Computer use is the switch row; its permission rows indent under it and hide while it is off. On the device every action is offered, from another device only Update and Sign in; the first action/error row's pill is primary. No subtitles, no footers.

WebDeviceReadinesspackages/ui/src/device-readiness.tsx
Desktoprender_sectionsapps/desktop/crates/ui/src/device_readiness.rs
iOSDeviceReadinessViewapps/ios/Exponential/UI/Components/DeviceReadinessView.swift
AndroidDeviceReadinessBlockapps/android/app/src/main/java/com/exponential/app/ui/components/DeviceReadiness.kt

3 Special components · Feedback

Usage windows

usage-bar

Every rate-limit window the machine reported, TWO lines each: the window's name left and its countdown right, then the meter with the percent (tabular, "NN%", never "62% used"). Stale numbers dim the whole block to 50% and add one "as of …" line — they are never hidden, because aged numbers still beat none.

Current sessionresets in 1h 4m
All modelsresets in 1h 14m
WebUsageWindowsapps/web/src/components/agent-usage-bar.tsx
Desktoprender_usage_windowsapps/desktop/crates/ui/src/usage_bar.rs
iOSUsageWindowsapps/ios/Exponential/UI/Session/AgentUsageCards.swift
AndroidUsageWindowsapps/android/app/src/main/java/com/exponential/app/ui/session/AgentUsageBar.kt

3 Special components · Feedback

Usage mini

usage-mini

The same report in one line: up to three windows (the five-hour one, the week, the first per-model one) as wire label · 4px meter · percent. It sits under an account row that is not the one the run spends — the other accounts in the usage overlay, every login under a device, and the account picker's hover preview — where the two-line form would not fit and the long titles would not either.

5h4%
Week73%
Fable100%
WebUsageMiniapps/web/src/components/agent-usage-mini.tsx
Desktoprender_usage_miniapps/desktop/crates/ui/src/usage_bar.rs
iOSAgentUsageMiniapps/ios/Exponential/UI/Session/AgentUsageCards.swift
AndroidAgentUsageMiniapps/android/app/src/main/java/com/exponential/app/ui/session/AgentUsageBar.kt

3 Special components · Feedback

Context ring

context-ring

EXP-877: how full the agent's context window is, as a 16px radial where the context pill used to be — and the trigger of the usage overlay the session already had. The arc is a stroked circle rotated a quarter turn, the track the same circle at 20% opacity, and the tone is the session's, not the ring's: the app maps its own thresholds onto normal / warning / danger and passes one in. A run nothing has measured renders NOTHING unless `showEmpty` says the run has other usage worth opening.

WebContextRingpackages/ui/src/context-ring.tsxringGeometry + RING_TONE_CLASS ship with it; the app's lib/context-ring.ts derives percent and tone
Desktopusage_sheet::context_ringapps/desktop/crates/ui/src/usage_sheet.rsmounted by steer_viewer::render_context_ring; the percentage comes from usage_bar::context_percent
iOSContextRingapps/ios/ExpUI/Sources/ContextRing.swift
AndroidContextRingapps/android/app/src/main/java/com/exponential/app/ui/components/ContextRing.kt
Full size screenshot