Skip to content

ADR-015: Interaction Contracts

FieldValue
TypeSkill Resource
Source~/.copilot/skills/adr/references/adr-015-interaction-contracts.md
DescriptionNot specified

Source Content

ADR-015: Interaction Contracts

Decision

External links, expandable navigation parents, and view-meaningful UI state each follow a fixed interaction contract: external links always carry a visible indicator, nav parents separate disclosure from navigation, and view state lives in the URL. Never hand-roll these affordances.

Key rules

External links:

  • Every link that navigates outside the application MUST show a visible external indicator (a trailing icon or equivalent).
  • Use the design-system Link component with the external variant — never a bare <a> for external links.
  • target="_blank" alone is not the indicator; the visible icon is always required.
  • Applies on every surface: prose, nav items, table cells, and adornments.
  • The indicator icon is a trailing icon and is aria-hidden="true"; the link’s accessible name communicates that it opens externally.

Hamburger / tree nav parent items:

  • Leaf item with href: clicking navigates and closes the menu.
  • Parent with children and no href: the item only toggles its disclosure — it MUST be a <button>, never an <a>.
  • Parent with children AND href: the label navigates while a separate chevron <button> toggles the children. Never navigate and expand on the same tap.
  • Keyboard: Enter or Space on a disclosure-only parent toggles; Enter on a linked parent navigates.

URL-synced UI state:

  • View-meaningful state (active tab, selected filters, search query, pagination, wizard step) MUST be URL-backed — never useState or useRef alone.
  • Import from the @dmwd-io/design-system/url-state subpath.
  • Inject UrlStateAdapter from the consuming app — the design system does not own the router.
  • Use the typed hooks: useUrlEnum, useUrlString, useUrlBoolean, useUrlNumber.
  • Ephemeral state (hover, tooltip open, animation in progress) does NOT need to be URL-backed.

Why

A fixed contract for each affordance removes the recurring bugs that come from hand-rolling them: external links with no indicator, parent nav items that both navigate and expand on one tap, and view state that vanishes on refresh or cannot be shared. Routing the chevron toggle through a real <button> and giving every interactive parent keyboard handling keeps the contract accessible. The original ADR-015 source was Proposed and covered only URL-synced state; this record is accepted and consolidates the external-link and nav-parent contracts, so it supersedes ADR-015 and ADR-015. The external-link indicator is reconciled to current icon practice (ADR-009): it is a trailing icon, marked aria-hidden, with the “opens externally” meaning carried by the accessible name rather than the icon.

Applies when

You are adding a link that leaves the app, building or modifying nav with expandable parents, or deciding how to persist view-meaningful UI state across navigation or page refresh.

  • ADR-009 — icon role, placement, and layout (the external-link indicator is a trailing icon).
  • ADR-027 — default tech stack (TanStack Router supplies the UrlStateAdapter).
  • ADR-012 — input & control sizing (form rows).