# Minddy interface design guide The single reference for anyone, person or agent, who changes Minddy's interface: the visual direction, the tokens and components to build with, and the rules every screen follows. Read it before an interface change and update it in the same change when a rule moves. Primitives live in `components/ui/` and design tokens in `app/tokens.css` (imported by `app/globals.css`, with palettes in `app/theme-presets.css`). The "UI components" section of `AGENTS.md` explains how to change a primitive. UI mock-ups for the core surfaces are in `assets/figma/`; follow them closely when redesigning those surfaces. The logo, the brand colors and the voice outside the interface are in [the brand guide](docs/brand.md). ## Direction - **Simplicity is the product.** Fewer controls, fewer words, one obvious way to do each thing. A new option, panel or badge has to earn its place against the calm of the screen around it. - **Content first, chrome second.** Issues, pages and conversations sit on a white (or near-black) floating pane; navigation lives on the quieter sidebar chrome around it. Decoration never competes with the work. - **Restraint in type and color.** Hierarchy comes from size, weight and spacing, not from color. minddy's color is lavender (`#7059C4` in light mode, `#B6A5F0` in dark mode): it is the default accent (`primary`, the focus ring, switches and segmented controls) and the identity color (`brand`). Keep one accent action per view; secondary actions stay outline or ghost, so the lavender stays light. - **Details never move the layout.** Hover states, loading and disclosures appear in place without shifting what the reader is looking at. - **Every gesture has one home.** A command lives in the place where it acts, is reachable from the command palette, and shows its keyboard shortcut where it is offered. ## Tokens and color Use semantic Tailwind utilities generated from the tokens; raw hex, `oklch()` or Tailwind palette colors (`bg-blue-500`) belong only in the token files, where `lib/theme-presets.test.ts` measures their contrast. | Need | Use | | --- | --- | | Page and text | `bg-background`, `text-foreground`, `text-muted-foreground`, `text-tertiary` | | Borderless controls (inputs, selects, outline buttons, chips) | `bg-control`, `hover:bg-control-hover` | | Cards and raised or sunken areas | `bg-card`, `bg-surface-raised`, `bg-surface-sunken` | | Status surfaces | `bg-surface-info`, `-success`, `-warning`, `-danger` | | Identity accent | `bg-brand`, `text-brand`, `text-brand-foreground` | | Danger | `text-destructive`, `bg-destructive-surface`, `bg-destructive-button` | | Lines | `border-border`, `border-border-subtle` | | Page and mention palettes | named attributes such as `data-page-text="blue"` | - **Modes.** Light, dark or system, saved to the account and applied before first paint (`lib/account-theme.ts`, `components/theme-init-script.tsx`). Style dark mode with the `dark:` variant only when a token cannot express the difference. - **Palettes.** Each mode has its own palette (`lib/theme-presets.ts`). Accent palettes change only `primary`, `ring` and the sidebar accent, never `brand`; "Noir" (light) and "Blanc" (dark) give ink actions instead of lavender. Full palettes (Solarized, Catppuccin, Nord) recolor neutrals, text, borders and primary. Status, priority, destructive, project and mention colors never change with the palette, so never encode meaning in a token a palette recolors. - **High contrast** adds borders to controls, edges to floating surfaces and a forced focus ring. Check a new surface in it. - **Transparency.** Menus, popovers and `[data-floating-surface]` elements are frosted glass by default; `html[data-transparency="off"]` (and high contrast) makes them opaque. Mark a new floating surface with `data-floating-surface` rather than styling its blur by hand. ## Type, shape and spacing - **Fonts.** Inter for interface text (sans, mono and display all resolve to it); Instrument Serif for the rare editorial accent; `--font-kbd` for key caps. Extra sizes: `text-2xs` (10px), `text-3xs` (11px), `text-md` (13px). A settings screen's title is the title line of its header bar (`SettingsShell`), not a heading above the cards. - **Radius.** `--radius` is 1rem and the scale is relative to it (`rounded-sm` to `rounded-4xl`); note that `rounded-md` is 14px here, not Tailwind's 6px. Buttons are pills (`rounded-full`). The floating content pane and the modals that mirror it use `--app-pane-radius`. Nested shapes keep concentric radii (`components/entity-pill.tsx`). - **Spacing tokens.** `h-header` (60px), `w-sidebar` (256px), `size-icon` (16px) and the dialog sizes `--spacing-dialog-w/h`. There are no custom shadow tokens: use `shadow-sm`, `shadow-md` or `shadow-lg`. - **Cursor.** The default arrow everywhere, never the pointer hand (MIN-587); text fields, drag handles and disabled controls keep their own cursor. ## Layout and responsive surfaces - **Two layouts, not widths.** App chrome follows `html[data-app-layout]` (`lib/app-layout.ts`): use the `app-mobile:` and `app-desktop:` variants. The choice weighs width (desktop from 1024px, 1200px in portrait), the shorter side of phones, touch and orientation. Plain width breakpoints (`desktop:` 1024px, `wide:` 1200px) are for content grids only. - **Surfaces adapt, callers don't.** `Dialog`, `AlertDialog` and `SidePanel` become bottom sheets on phones; `ResponsivePopover` is a popover on desktop and a sheet on mobile; `DropdownMenu` submenus become inline accordions below 768px. Use plain `Popover` only for a surface that must stay anchored on mobile. From 640px in the mobile layout (an unfolded phone, a tablet in portrait, a phone in landscape), every bottom sheet floats: still anchored at the bottom, with a width capped at 40rem, side gutters and four rounded corners. Size a custom sheet with `--mobile-sheet-room`, `--mobile-sheet-float` and `--mobile-sheet-safe-bottom`, never with the raw viewport height or bottom inset. - **The keyboard.** In the app shell the document never scrolls, even when iOS Safari would scroll it to reveal a focused field: `useMobileViewport` in `lib/use-mobile-layout.ts` prevents it, announces the keyboard as soon as a field takes the focus (at the height measured last time) and publishes everything a surface needs to make room. Never measure the keyboard from `window.innerHeight`, which iOS shrinks with it. To adapt a field: - a surface anchored at the bottom uses `bottom: var(--mobile-keyboard-room)` (the keyboard plus the editor's strip) and caps its height with `--mobile-visible-height` or `--mobile-sheet-room`, and pads its bottom with `--mobile-sheet-bottom` (or `--mobile-sheet-safe-bottom`), never the raw `env(safe-area-inset-bottom)`, which the keyboard covers; mobile sheets, `AlertDialog`, the drawers and the palette's actions already do; - a composer docked at the foot of a page or panel carries `data-keyboard-dock`: it hides while another field holds the keyboard and drops its home-indicator padding while it holds it; - a field in the workspace itself needs nothing: the shell shrinks to the visible height while it is focused, and the field is scrolled into view inside its own scroll area. - a menu placed by hand next to the caret reads the room above the keyboard from `visibleBand` or `editorMenuBand` in `lib/visible-band.ts`; - a sheet opens with the keyboard down and focuses itself (`allowInputAutoFocus`, `focusMobileSheet` in `lib/mobile-sheet-focus.ts`). The command palette is the exception: it opens on its search field, and the tap that opens it calls `primeMobileKeyboard` so iOS raises the keyboard. - **Touch.** Under a coarse pointer, controls get a 44px hit area through the helpers in `components/ui/touch-target.ts` without changing how they look. Rows use `COARSE_ROW`. Hover effects belong to a fine pointer: `hover:` (and `group-hover:`) only apply under `(hover: hover) and (pointer: fine)`, and a hand-written `:hover` rule sits inside that media query. Hover-only affordances must also be reachable on touch: add `touch:opacity-100` to an `opacity-0 group-hover:opacity-100` control, or move it into a menu. - **Safe areas and the mobile nav.** Respect the device insets the shell already pads on all four edges, including the side insets of landscape, and keep fixed content above the mobile nav with `--mobile-nav-clearance` or `.dock-above-nav`. Under a coarse pointer every text field and editable host is at least 16px, or iOS zooms in on focus. ## Components Build with `components/ui/` and the shared app components before writing a new one; a variant with different behavior gets its own name. | Need | Component | | --- | --- | | Action | `Button` (`default`, `outline`, `ghost`, `destructive`, `link`; sizes `sm`, `default`, `lg`, `icon-sm`, `icon`, `icon-lg`) | | Icon-only action | `IconButton` (ghost, requires `aria-label`) or `Button` with an icon size | | Primary action with options | `SplitButton` | | Exclusive choice of two to four | `SegmentedControl` | | Field picker (status, assignee, category…) | `SearchSelect` / `SearchMultiSelect` (`components/search-select.tsx`) | | Searchable single choice in a toolbar | `Combobox` | | Property row in a side panel | `PropertyRow` and the fields in `components/issue-property-fields.tsx` | | Small form in a modal | `FormDialog` (`components/form-dialog.tsx`) | | Confirmation of a destructive action | `ConfirmDeleteDialog` | | Other decision that blocks | `AlertDialog` | | Larger task in a modal | `Dialog` | | Detail next to a list | `SidePanel` | | Anchored menu | `DropdownMenu`; right-click and `⋯` menus on objects: `IssueContextMenu` / `IssueActionsMenu` | | Settings layout | `SettingsGroup`, `SettingsRow`, `SettingsListRow`, `SettingsEmpty` (`components/settings/settings-ui.tsx`) | | Status, priority, effort marks | `StatusIndicator`, `PriorityIndicator`, `EffortIndicator` (`components/issue-indicators.tsx`) | | Category or option color | `Dot` (`components/ui/color-dot.tsx`) | | Label or count | `Badge` | | Tooltip | `AppTooltip` (see below) | | Key cap | `Kbd`, `KbdSequence` | | Empty screen | `EmptyState`; illustrated: `EmptyScene` (`page` or `compact`), whose `IsoIcon` draws any icon on a field of dots in the accent color (`destructive` for deletion) | | Loading screen | the matching skeleton in `components/route-skeletons.tsx` | | Notification | `toast` from `sonner` | ## Icons - Hugeicons (`HugeiconsIcon` with `@hugeicons/core-free-icons`) is the icon set; render a mix of sources through `AppIcon` (`components/icon.tsx`). A few Lucide icons remain while the migration ends; don't add new ones. - Sizes: `size-4` by default (buttons size their icons for you), `size-3.5` in small buttons and dense rows, `size-3` inside chips. Keep the stroke width the set uses. - An icon never carries meaning alone: it has a visible label or a tooltip. ## Labels, tooltips and shortcuts Every control is named, and every name is reachable without guessing. - **Icon-only controls always have a tooltip** that says what the click does ("Copy link", "Close panel"), not what the icon is. The tooltip is the control's label: the same text as its `aria-label`. - A `Button` (or `IconButton`) with an icon size shows its `aria-label` as its tooltip automatically. Pass `tooltip` to show richer content, or `tooltip={false}` only when visible text next to the button already names it. - Any other icon-only control (a raw `