Agent docs

Identity (IdentityRow / WorkspaceSwitcher)

IdentityRow and its avatar: the person, workspace, or org strip.

Two components sharing one source directory: IdentityRow, the avatar-plus-name/subtitle strip used wherever a person, workspace, or org is presented as a row, and WorkspaceSwitcher, the org/workspace picker built entirely on top of it. Kit source: packages/ui/src/identity/.

The canon behind it. docs/design-language/00-overview.md model 2 (00-overview.md:65, identity is not status, never encode state in an identity color) and docs/design-language/08-navigation-and-shell.md §A6 (08-navigation-and-shell.md:29, identity sits at the sidebar foot, sign-out lives separately), plus docs/design-language/02-buttons-and-actions.md §A2 (02-buttons-and-actions.md:17, no colour at all marks a dead-end or exit action).

IdentityRow#

When to reach for it#

Sidebar footers, account menus, workspace pickers, team-member lists: anywhere a person or workspace needs an avatar plus a title/subtitle pair. It is purely presentational (identity-row.tsx:13-18); activation, a link, a dropdown trigger, a plain non-interactive row, is entirely the caller's choice of wrapper.

Contract#

  • avatar: initials/emoji for the fallback disc. image: a photo URL, wins over avatar when present.
  • title (required) / subtitle: through renderNode, string or JSX.
  • trailing: a free trailing slot (chevron, cog button, badge). TrailingIcon: registry-icon shorthand for the common single trailing glyph; trailingIconClassName overrides its default muted color.
  • size: "sm" | "md", default "md". Two fixed scales for avatar/title/subtitle sizing (identity-row.tsx:8-11).
  • skeleton: an avatar disc plus two text bars.
  • Slot classnames per IdentityRowClassNames: "" the row, avatar, title, subtitle, trailingIcon.

Real usage#

Walmart-only in shipped app code: a team-member list with skeleton rows (walmart-mvp/frontend/src/pages/Settings.tsx:468-489, avatar + title + subtitle + size="sm") and the sidebar user menu (walmart-mvp/frontend/src/App.tsx:209-215, the same three props). No speedway app usage found. Every shipped call passes only avatar, title, subtitle, and size="sm"; image, trailing, and trailingIconClassName have no confirmed shipped app call site. TrailingIcon does have a working example, but only inside the kit itself: WorkspaceSwitcher uses it for both its trigger's caret (TrailingIcon="Down") and its own row's selected checkmark (TrailingIcon={ws.id === current.id ? "Success" : undefined}), see below.

WorkspaceSwitcher#

When to reach for it#

The org/workspace picker that sits under the logo in a sidebar: the current workspace as the trigger, the rest in a floating list, a divider-led create row.

Contract#

  • workspaces: WorkspaceItem[] ({ id, name, subtitle?, avatar?, image?, disabled?, disabledReason?, loading? }). A disabled item is listed but not pickable: the row reads at half ink with cursor-not-allowed, and disabledReason (a workspace still provisioning, a section not built yet) takes the subtitle's place. A loading item is a skeleton row holding its place until its name arrives (owner ruling 2026-08-16; workspace-switcher.tsx:70-100).
  • value / onChange(id, workspace): value-first, the full item rides second.
  • onCreate: renders the divider-led creation row; omit to hide it entirely. createLabel default "New workspace".
  • onViewAll: renders a "view all" row under the creation row; omit to hide it. viewAllLabel default "View All".
  • skeleton: renders <IdentityRow skeleton size="sm">.
  • placement: a floating-ui Placement, default "bottom-start". The panel only matches the trigger's width for bottom placements (matchTriggerWidth = placement.startsWith("bottom"), workspace-switcher.tsx:40); a sidebar seat that opens sideways does not get width-matched.
  • Slot classnames per WorkspaceSwitcherClassNames: "" the trigger row, panel the floating list.

Built entirely on the kit's Dropdown for positioning and IdentityRow for every row, including its own trigger.

Real usage#

Live in walmart only: App.tsx:501-514, mounted as AppShell's sidebarTop slot, workspaces mapped from the user's orgs, onCreate opens a new-org modal, no onViewAll (the view-all row is omitted).

Removed from speedway. speedway/app/root.tsx:498-511 has the entire call site commented out, with an explicit revival note: "DEPRECATED 2026-07-21 (doc-29): topbar workspace switcher hidden ... Revive: restore the WorkspaceSwitcher + its import." speedway/app/components/WorkspaceNav.tsx:12,334 carries the same dormant marker. Read this as a product decision to hide the workspace concept from users, not as evidence the component is broken or unmaintained; the commented block is preserved verbatim specifically so it can be revived.

Sanctioned combinations#

CombinationProducesWhere usedWhy
IdentityRow with avatar (initials) + title + subtitle + size="sm", no image, no trailingA compact person/workspace row inside a menu or listSettings.tsx:468-489, App.tsx:209-215Every shipped identity is drawn from initials, not a stored photo, so image has never been reached for
WorkspaceSwitcher as AppShell's sidebarTop, onCreate set, onViewAll omittedThe sidebar workspace picker with a create row and no "view all" rowApp.tsx:501-514The app has no separate all-workspaces page today, so the row is simply not offered

Banned combinations#

Do not colour the exit. When IdentityRow is the trigger of an account menu, the sign-out row that follows it carries no colour: "No color at all marks a dead-end/exit action" (docs/design-language/02-buttons-and-actions.md:22). Walmart's live SidebarUserMenu still colours it text-error and is the recorded lapse, not the pattern.

Do not tint the avatar disc or the title by status. The identity vocabulary says WHO a thing is; the status vocabulary says how it is doing, and the canon's rule is "Never encode state in an identity color or vice versa" (docs/design-language/00-overview.md:66). A pending invite or a suspended member reads its state from a StatusPill beside the row, never from the row's own colour.

Do not hang onClick on a bare IdentityRow and call it a control. The row is purely presentational (identity-row.tsx:13-18): activation, focus, and the keyboard path belong to the wrapper, a <button>, a link, or a Dropdown trigger, which is how both shipped seats do it (walmart-mvp/frontend/src/App.tsx:209-215).

Do not give one shell two workspace pickers. WorkspaceSwitcher has one live mount point, AppShell's sidebarTop (App.tsx:501-514); a second picker in the topbar or a page is the duplicate-chrome defect app-shell.md's checklist opens with.

Before you adopt this#

Five questions to answer before reaching for IdentityRow/WorkspaceSwitcher.

  1. Does the shell, a parent layout, or a global provider already render this? WorkspaceSwitcher has one live mount point, AppShell's sidebarTop; a second picker duplicates it.
  2. Does this app already ship a local implementation of the same thing? Not applicable, no local IdentityRow/WorkspaceSwitcher clone is documented in either app.
  3. Does this app's kit pin reach the version this component or prop landed in? WorkspaceSwitcher's disabled/loading item landed by owner ruling 2026-08-16; confirm the pin.
  4. Does the component derive its own accessible name and keyboard path, or must the call site supply them? IdentityRow is presentational; the wrapper <button>, link, or Dropdown supplies focus and click.
  5. Which canon §A rules bind this surface, and which does the composition break? §A6 keeps sign-out separate from identity; identity colour never marks status.

Travels with#

  • Dropdown, the positioning primitive WorkspaceSwitcher is built on.
  • AppShell's sidebarTop slot, WorkspaceSwitcher's one live mount point.

Snippet#

// walmart-mvp/frontend/src/App.tsx:507-514
<WorkspaceSwitcher
workspaces={orgs.map((o) => ({ id: o.id, name: o.name, subtitle: o.role }))}
value={activeOrg.id}
onChange={(id) => selectOrg(id)}
onCreate={() => openModal(NEW_ORG_MODAL)}
createLabel="New organization"
/>
@versable-git/ui · reference, canon, and method, read in place