Ctrl K
ComunidadGuías…

DESIGN.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# 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 `<button>`, a link, an avatar, a color
    dot) is wrapped in `AppTooltip`.
- **Text that can be cut has a tooltip with the full text.** When a title,
  name or path is truncated (`truncate`, `line-clamp-*`), use
  `TruncatedText` (`components/ui/truncated-text.tsx`), which shows the full
  text in a tooltip only when it is actually cut.
- **Shortcuts are shown where the action lives.** When a keyboard shortcut
  performs the same action, the tooltip includes it as key caps: pass
  `shortcut={["⌘", "K"]}` to `Button` or `AppTooltip` (resolve ⌘ or Ctrl with
  `useModKey()`). `Kbd` hides itself in the mobile layout.
- **Never use the native `title` attribute** for a tooltip: it is slow, can't
  show key caps, and ignores the theme. Use `AppTooltip`.
- The default close cross of `Dialog`, `Sheet` and `SidePanel` is
  `CloseButton` (`components/ui/close-button.tsx`): localized name, Esc in its
  tooltip. Reuse it for a custom close button rather than redrawing one.
- Tooltips open on hover and on keyboard focus only (`lib/tooltip-focus.ts`);
  they never hold essential information that a touch user can't reach
  another way.

## Destructive actions ask first

No gesture removes data, access or a working link in a single click. Every
action that moves something to the trash, deletes it, revokes it, unlinks it,
or takes someone out of a project opens a confirmation dialog before anything
is sent to the server.

- **One component.** Use `ConfirmDeleteDialog`
  (`components/ui/confirm-delete-dialog.tsx`). It is an alert dialog: a click
  outside does not dismiss it, the confirm button carries the destructive
  style, and both button labels are required so neither falls back to an
  untranslated default. Do not use `window.confirm` or a plain `Dialog`.
- **Name the target.** The title says what goes: `Move “{title}” to the
  trash?`, `Remove {name} from the project?`, `Delete 3 options from
  “Status”?`. Use a count when the action covers a selection.
- **Say whether it comes back.** The description states the outcome:
  - trash: restorable for `TRASH_RETENTION_DAYS` days, from the trash;
  - relink or re-invite: what stays and how to undo it;
  - permanent: "This can't be undone."
- **Confirm the action, not the entry point.** Context menus, `⋯` menus,
  command palette rows, bulk actions and settings buttons all reach the same
  dialog. A menu item or palette row only names the target; the mutation runs
  in the dialog's `onConfirm`. Keep that state in the shared hook or provider
  when several surfaces trigger the action (`usePageDocumentMenu`,
  `PrUnlinkConfirmationProvider`, `RoutineTrashDialog`).
- **Keyboard shortcuts** must not delete directly. If one is added, it opens
  the same dialog.
- **Errors keep the dialog honest.** For an action that can fail and is worth
  retrying, pass `closeOnConfirm={false}` and close the dialog only on
  success.

Exceptions, which need no dialog:

- edits inside a rich-text editor that `⌘Z` restores (removing a block that
  holds a sub-page or database still confirms, because it trashes real pages);
- removing something not yet saved or sent (a draft attachment, a wizard
  invitee before the project exists);
- reversible toggles and navigation state (closing a tab, turning the
  feedback board off, removing an issue from a cycle, showing a hidden
  project again);
- automatic clean-up with no user gesture (discarding an empty draft page).

## Feedback, loading and empty states

- **Optimistic first.** Edits apply at once and reconcile with the server
  (`lib/optimistic/`); a failure rolls back and says so in a toast.
- **Toasts are plain.** One short sentence, no action button and no "Undo":
  recovery lives where the object went (the trash, history). Use
  `toast.success` for a completed gesture that left the screen, `toast.error`
  with the server's message for a failure, and nothing for a change the user
  can already see.
- **Loading.** Show the matching route skeleton, shaped like the real layout,
  and read React Query's `isPending`, never `isLoading`, so an empty state
  never flashes before data arrives (`lib/query-loading.test.ts`). A region
  inside a screen (a list, a panel tab, a dialog body) shows a skeleton shaped
  like its content (`components/loading-skeletons.tsx`), never a spinner; a
  status line for running work shimmers its label (`text-shimmer`). Use
  `Spinner` only inside the control that is working.
- **Empty states** say what the area is for and offer the one action that
  fills it. Don't render an empty frame just to say it is empty.

## Interaction

- **Keyboard.** Shortcuts are declared once in `lib/keyboard/shortcuts.ts`
  (the cheat sheet reads them) and use the `mod` token for ⌘ or Ctrl. Hover
  shortcuts act on the item under the pointer (`lib/keyboard/hover-keys.ts`).
  Single-letter shortcuts never fire while typing (`isTypingTarget`). Submit
  and send keys go through `components/send-shortcut.tsx`.
- **Command palette.** ⌘K is the hub for navigation and actions, including
  bulk actions on a selection. A new object action belongs in the shared menu
  actions so the palette, the context menu and the `⋯` menu stay identical.
- **Overlays.** Escape closes the topmost layer only (`lib/overlay-layers.ts`);
  a dialog or side panel stays open while a nested popover is used
  (`keepOverlayOpenForPopper`).
- **Scrim.** Every modal layer (dialogs, wizards, alert dialogs, sheets and
  side panels) dims the whole app behind it with the one `--overlay-scrim`
  token (`bg-scrim`), set in `app/tokens.css` for both themes. Menus,
  popovers and the command palette float without it, and the Numo shell keeps
  its own faint tint. Do not give a call site its own scrim color or a
  backdrop blur.
- **Motion.** Short and functional, with the shared springs in
  `lib/motion.ts`. `prefers-reduced-motion` stops animations globally; never
  rely on motion to convey state. The primitives hold these rules, so a call
  site does not restate them:
  - A surface enters with `ease-out` and leaves about 20% faster than it
    entered. Menus, popovers, selects, hover cards and tooltips take 150ms in
    and 120ms out; dialogs 200ms and 150ms.
  - A floating surface grows from 0.95, never from 0, and from the control
    that opened it (the Radix transform origin), and it leaves toward it.
  - Something that travels across the screen (the tabs indicator) uses
    `ease-in-out`. Something that follows the pointer is not eased while held.
  - Springs are critically damped: everyday interface motion does not
    overshoot.
  - A button sinks to 0.97 while pressed under a coarse pointer only; a fine
    pointer already has hover.
  - A copy button uses `CopyStateIcon`, which cross-fades the two icons with
    a scale and a fade.
- **Selection.** Interface text is not selectable by default; content is
  (`select-text`, inputs and code).

## Copy

- How to write any visible text (emphasis budget, length, tells to remove) is
  set by [`.claude/skills/no-slop-writing/SKILL.md`](.claude/skills/no-slop-writing/SKILL.md).
  The rules below are this product's conventions on top of it, and win where
  they differ.
- Interface copy goes through `next-intl` in all six catalogs (`en`, `fr`,
  `de`, `es`, `it`, `pt-BR`); see the i18n section of `AGENTS.md`.
- Sentence case, short, in the user's words. Name the object, not the
  implementation. Use typographic quotes around names: “{name}”.
- In English an item of work is an "issue"; in French a "ticket".
- Show people by their display name (`lib/display-name.ts`), never a raw
  email.
- Never show prices in dollars inside the app: AI usage is a percentage of the
  plan's budget. Dollars appear only on the admin finance view.
- Marketing pages keep blocks short (8 to 16 words) and never cite keyboard
  shortcuts.
- **A placeholder says what to enter, never a sample value.** Ask the question
  the field answers or name what goes in it ("Which tasks are assigned to
  {name}?", "Your email address", "Address of the MCP server"), not an
  example of an answer (`you@example.com`, `https://example.com`, "e.g. …",
  `sk-…`, `#xxxxxx`). A sample reads like a value already typed and vanishes
  on the first keystroke. When a format, a default or an example really
  helps, put it in the hint under the field or in the validation message.
  Placeholders come from the catalogs, like every other visible string.

## Accessibility

WCAG 2.2 AA is the target. [docs/accessibility.md](docs/accessibility.md)
holds the automated checks and the manual test protocol.

- Every control has an accessible name; icon-only controls carry it in
  `aria-label` (`IconButton` requires it). Names come from the catalogs;
  only proper names such as “minddy” are written literally (the spinner's
  English default is the one open exception).
- A control that shows a value names it too: a field trigger (status,
  priority, assignee, date…) reads “Status: In progress”, never only “Change
  status” (`IssueUI.fieldValueAria`). A glyph that is the value's only trace
  is an image named by it.
- Focus is visible from the keyboard. While Tab drives focus,
  `data-input-modality="keyboard"` on `<html>` draws the ring outline around
  the focused control (`lib/keyboard-modality.ts`, `app/globals.css`); a click
  clears it. A component may draw its own focus on top of it, but never
  relies on removing it. High contrast also forces a focus ring.
- The signed-in shell starts with a **Skip to content** link to its
  `<main id="main-content">` (`components/app-shell.tsx`).
- Every editing surface is a named multi-line text box: `RichEditor` takes
  `ariaLabel`, else the text placeholder, else “Text editor”.
- A message that appears after an action, away from focus, is announced:
  `role="alert"` for errors, `role="status"` for progress. A field's own
  error marks it `aria-invalid` and describes it (`aria-describedby`).
- Text keeps 4.5:1 contrast and focus rings 3:1 in every palette (7:1 for
  high-contrast text), as `lib/theme-presets.test.ts` checks.
- Don't convey state by color alone: pair it with an icon, a label or a
  shape (status indicators do).
- framer-motion animations follow `prefers-reduced-motion`, through
  `<MotionConfig reducedMotion="user">` or `useReducedMotion`; a surface
  mounted outside the app providers handles it itself.
- A new drag needs an equivalent without dragging, and the screen-reader
  instructions of a `DndContext` describe the keys that actually work. The
  manual order of a board does not have its equivalent yet.

## Checks that enforce this guide

- `lib/theme-presets.test.ts`: palette contrast and high-contrast boundaries.
- `lib/mobile-touch.test.ts`, `lib/mobile-nav-clearance.test.ts`,
  `components/ui/responsive-surfaces.test.tsx`: touch targets and adaptive
  surfaces.
- `components/ui/button.test.tsx`, `components/ui/close-button.test.tsx`:
  icon-only buttons label themselves, and dialog crosses are localized.
- `lib/tooltip-focus.test.ts`, `lib/overlay-layers.test.ts`: tooltip focus and
  Escape ownership.
- `lib/query-loading.test.ts`: no empty-state flash while loading.
- `lib/i18n-contract.test.ts`: catalog parity and placeholders.
- `lib/placeholder-copy.test.ts`: input placeholders hold no sample values
  and no hardcoded strings.
- `lib/ui-components-ownership.test.ts`: the primitives stay owned here.
- `lib/accessibility-contract.test.ts`: translated accessible names,
  announced errors, image alternatives, the keyboard focus rule and
  reduced motion. `components/app-shell.test.tsx`,
  `lib/keyboard-modality.test.ts` and `components/editor/document-editor.test.tsx`
  cover the skip link, keyboard focus and editor names at runtime.