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. method/design-language/00-overview.md model 2 (00-overview.md:65, identity is not status, never encode state in an identity color) and method/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 method/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.
  • icon: a registry icon in the avatar disc instead of initials, for an entity that is not a person (a team, a file, a stage). image still wins over it.

In a table cell#

An owner, team, file or stage in a table cell is an identity row, not plain text. size="cell" is the one-line cell shape:

  • Outside a cell, a long title or subtitle truncates and shows its full text on hover when cut. The initial on the default avatar uses base ink on the primary tint (AA in light); dark keeps primary-content on solid primary.
  • A small avatar, no row padding, and the title truncated to one line. The full title shows on hover only when it is cut.
  • subtitle does not take a second row. It becomes the second line of a two-line tooltip under the title. tooltip={false} turns the tip off; a node as tooltip replaces the subtitle as the second line.
  • truncate="middle" (with truncateAt, default 28) keeps both ends of an identifier such as a file name or an id and drops the middle. The tooltip then carries the whole value.
  • A blank title (null or empty text) shows the table's N/A mark, or whatever empty says.

Use the column factory rather than writing the cell by hand: col.entity(key, row => ({ title, subtitle, avatar | icon | image, truncate }), header). The column is shaped name, so the table gives it room before the short columns, and sorts and copies by the title. col is the same object as before with entity added.

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.

Laws it enforces#

  • Fit, then truncate, never clip. A cell title truncates to one line and shows its full value on hover; an identifier cuts its middle so both ends stay readable.
  • Show only what has something to say. The tooltip appears when there is a second line or something is cut, and a plain short title stays quiet.
  • Machine values stay exact, human text stays human. A blank owner says N/A, and an id is cut in the middle, never recased.

Sanctioned combinations#

CombinationProducesWhere usedWhy
col.entity with title, subtitle and avatar or iconAn owner, team, file or stage cell that is one line with a two-line tooltipapps/playground/src/app/components/identity/examples/entity-cells.tsxAn entity in a cell is an identity row, so truncation, tooltip and the empty mark live in one place
col.entity with truncate: "middle" for a file name or idA cell that keeps the start and the extensionthe same exampleA cut at the end loses the part that tells two files apart
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" (method/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" (method/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 write an entity into a table cell as plain text, or hand-roll its truncation and tooltip. Use col.entity (or IdentityRow size="cell" inside a custom render). The cell shape never shows a second row, so a long subtitle cannot grow the table row.

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#

import { WorkspaceSwitcher } from "@versable-git/ui";
// 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