From 82e8554f75eca5ba6d5737c7587d3f94dece5bfd Mon Sep 17 00:00:00 2001 From: Maximilian Kaske Date: Tue, 13 Oct 2026 22:29:19 +0200 Subject: [PATCH] docs: add DESIGN.md with the UI conventions and principles Hierarchy, compound kits, cva variants, tokens, typography, motion, a11y, responsive and worst-case rules in one place; AGENTS.md says where code lives, this says what good looks like. Root AGENTS.md points at it. --- AGENTS.md | 1 + DESIGN.md | 281 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 282 insertions(+) create mode 100644 DESIGN.md diff --git a/AGENTS.md b/AGENTS.md index 229300d06..feae67c92 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,6 +77,7 @@ boundaries with external SDKs or at registry-style dispatch. When you need one: ## Package context +- `DESIGN.md` — what good UI looks like: hierarchy, compound kits, tokens, motion, a11y - `packages/services/AGENTS.md` — service verbs, audit log, scope enforcement - `packages/ui/AGENTS.md` — stock shadcn vs. the published blocks registry - `apps/dashboard/AGENTS.md` — Next.js runtimes, client boundary, UI verification diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 000000000..a09cdc4f9 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,281 @@ +# DESIGN.md + +How openstatus UI should look, feel and be built. `AGENTS.md` says where code +lives and what breaks; this file says what good looks like. Read both before +touching anything a user sees. Rules are short on purpose: the reason is the +rule, and the exception is in review. + +## Principles + +- **Hierarchy over decoration.** A page has one loud level and one quiet + level. Authored content (a note, an update, a postmortem) is loud. State + changes and system events are quiet. If everything is emphasized, nothing + is. +- **Quiet by default.** Start from `text-muted-foreground` and lift only the + word that matters to `text-foreground`. Borders, fills and color are earned. +- **Compose, don't invent.** A new screen is existing primitives in a new + arrangement. A new primitive is a reviewed decision, not a side effect of a + feature. +- **Unseen details compound.** Nobody notices a 160ms press or a + dashed-underline hover on its own. Together they are why the product feels + right. +- **Learnable by repetition.** The same shape on every incident, report and + maintenance teaches the workflow without a tour. Consistency across pages + beats a better idea on one page. + +## Components + +### Where things live + +| Layer | Path | Rule | +|---|---|---| +| shadcn primitives | `packages/ui/src/components/ui/` | Never customized. See `packages/ui/AGENTS.md`. | +| Public blocks | `packages/ui/src/components/blocks/` | Published registry. No app imports. | +| Shared custom | `packages/ui/src/components/custom/` | Ours, used by more than one app. | +| App kits | `apps/*/src/components/content/`, `common/` | Compound kits a whole app composes from. | +| Feature rows | `apps/*/src/components//` | What a feature renders with the kits. | + +A repeated visual is a new part in a kit, never a copied class stack. A +feature-specific row (an incident event, a report update) lives in the feature +folder and imports the kit; it does not add exports to the kit. + +### Compound components + +One kit per concept, small export list, every part a plain element wrapper: + +```tsx +export function TimelineItem({ children, className, ...props }: React.ComponentProps<"li">) { + return ( +
  • + {children} +
  • + ); +} +``` + +- Props are `React.ComponentProps<"tag">` plus the few the part owns. Spread + the rest, merge `className` with `cn`, always set `data-slot`. +- Parent context is expressed in CSS, not props: + `in-data-[slot=timeline-card]:text-foreground` lets an actor render muted in a + row and bold in a card without a `variant` prop. +- State is expressed through attributes the browser already sets: + `aria-pressed:`, `data-[state=open]:`, `aria-invalid:`, `disabled:`. Style + the attribute, never a parallel boolean class. +- Keep the kit at the number of exports it needs. `Timeline` has eleven. If + a feature needs a twelfth, first ask whether it is a row in the feature file. +- Children, not render props, for layout. Slot props (`renderMessage`, + `asChild`) only where a block must stay free of app dependencies. + +### Variants + +- `cva` for the visual axes of a part, `VariantProps` for the type. Status + variants are `default | success | warning | destructive | info`, pinned with + `satisfies Record` so a missing one fails typecheck. +- `className` is for layout (margin, width, grid placement). It is not for + restyling a part's colors or type; add a variant instead. +- Prefer a boolean switch (`dot`, `plain`) over a new variant when it only + removes chrome. +- `gap-*` on flex and grid, never `space-x-*` / `space-y-*`. `size-*` when + width equals height. `truncate` plus `min-w-0` on the flex child. + +### Primitives + +When a shadcn primitive is not enough, in order: pass `className`, wrap it in +a kit part, add a variant to the wrapper. Editing `ui/` is a last resort with a +comment saying why. Shared UI comes from `@openstatus/ui`; an app never forks a +primitive (`docs/adr/0003`). + +## Hierarchy and layout + +- **Two row kinds, one rail.** A list of events has a compact sentence row + (icon, muted sentence, time) and a card (avatar, header, body, optional + footer, actions top-right). The rule for which: an authored message is a + card, everything else is a row. The rail runs between every item, including + card to card and composer to first item. +- **Sentences, not labels.** A row reads "Thibault marked Resolved", not + "Status changed · Thibault". The actor and the lifted value carry the only + emphasis. +- **Composer first, newest first.** On anything time-ordered the input sits + on top and the latest item is directly below it. During an incident you want + to know what just happened. +- **Content left, properties right.** Detail pages are a main column + (header, composer, timeline, postmortem) and an aside of label/value + properties. The aside is reference; it never competes with the timeline. A + linked entity is one row with its actions behind a menu, not a card with a + primary button. +- **Section title plus count.** `Activity 10`, `Updates 3`. The count is mono + and muted. +- **Spacing.** Lists `gap-4`, cards `px-4`, inside a row `gap-2.5`, stacked + compact rows pull up with a short rail. Pages cap at `max-w-6xl`. Padding + is symmetric unless the asymmetry is explained in a comment. +- **Empty states** use the dashed `EmptyStateContainer` with a title and one + sentence that says what to do next. Never a bare "No data". +- **Inline editing.** Title and summary are borderless inputs that show their + edge on hover and focus. Save on blur or explicit action; never on every + keystroke. +- **Hover affordance** is a dashed, muted underline (`decoration-dashed + decoration-muted-foreground/50 underline-offset-2`). Not a color change, not + a chevron, not a dot. + +## Color + +- Tokens only: `background`, `foreground`, `muted`, `muted-foreground`, + `border`, `success`, `warning`, `info`, `destructive`. They are OKLCH in + `packages/ui/src/globals.css`. Never a raw palette class (`text-emerald-600`). +- Dark mode comes from the tokens. No `dark:` color overrides in components. + Images in content pages get a `.dark` sibling file. +- Status color sits on the smallest element that can carry it: a `StatusDot`, + the word in a sentence, the icon in a disc. Chrome stays neutral. +- Tinted chrome is one recipe: `border-{token}/20 bg-{token}/10 text-{token}`. + Inside a sentence use `StatusBadge plain` (dot and mono text, no border). + Bordered badges belong in cards and tables. +- Impact and status labels on the status page are colored text only. +- A new token goes into `registry.json` `cssVars` too, or it is undefined for + everyone who installs a block. +- Contrast: 4.5:1 for text, 3:1 for icons, focus rings and borders that + carry meaning. Muted text on a muted fill is the usual offender; check it in + both themes. + +## Typography + +- Sans for prose, labels and buttons. Mono for values: ids, counts, dates, + durations, statuses, anything a user might copy or compare. +- `tabular-nums` on any number that updates in place so columns do not jitter. +- No font sizes beyond the scale. Hierarchy comes from weight and color + before size: `font-medium text-foreground` for the lifted word, size only + for the page title. +- Relative time in the row (`2d ago`), absolute time with timezone in a hover + card. Both from `HoverCardTimestamp`; never format a date inline. +- Wrapped sentences are allowed. Icons top-align (`items-start`) so a two-line + row still reads. +- Sentence case everywhere. Brand is `openstatus`, lowercase, except at the + start of a sentence. Click paths use `>`: `Settings > Integrations`. + +## Radius + +The scale is proportional to the themed `--radius` (`xs` 0.2, `sm` 0.6, `md` +0.8, `lg` 1.0, `xl` 1.4). Prefer `rounded-lg`. Never hard-code a pixel radius. +`rounded-full` only wraps a glyph. The status page and the marketing site set +`--radius` to 0, so a radius in a demo is dead code. Details in +`packages/ui/AGENTS.md`. + +## Motion + +CSS transitions only. There is no animation library in this repo and none is +planned. + +- **Ask first whether it should move.** Something seen a hundred times a day + (command menu, keyboard shortcuts, list navigation) does not animate. + Hover and press get feedback, not motion. Modals, drawers and toasts get a + standard transition. Only rare moments may delight. +- **Durations.** Press feedback 100–160ms. Tooltips 125–200ms. Dropdowns and + selects 150–250ms. Modals and drawers 200–300ms. Nothing in the UI over + 300ms. +- **Easing.** `ease-out` for anything entering or leaving. `ease-in-out` for + movement on screen. `ease` for color and hover. Never `ease-in`. +- **Transition named properties**, never `transition-all`. Prefer + `transform` and `opacity`; they do not trigger layout. +- **Press feedback** on pressables we own (kit parts, blocks, custom): + `active:scale-[0.97]` with the press duration above, on press rather than + release. The stock `ui/button` stays untouched; add it through a wrapper + class, not by editing `ui/`. +- **Never enter from `scale(0)`.** Start at 0.95 or higher with opacity. +- **Popovers scale from their trigger** + (`origin-(--radix-popover-content-transform-origin)`). Modals stay centered. +- **Second tooltip opens instantly.** The delay guards the first hover only. +- **`motion-reduce:`** on every transition that moves or scales. Fades may + stay. +- `@starting-style` for enter animations when support allows, the + `data-mounted` pattern otherwise. No `useEffect` just to animate. + +## Interaction and accessibility + +- **Native elements.** A `button` acts, a link navigates. No `div` with + `role="button"`. Space and Enter both work on a button because it is one. +- **Every icon-only control has a label**: `aria-label` plus a tooltip that + shows the same text and the shortcut if there is one. Decorative glyphs + (`StatusDot`, rail icons) are `aria-hidden`. +- **Focus is visible.** Never remove the `focus-visible` ring; override it + with the token ring if a layout needs it. Keep focus inside overlays and + return it to the trigger on close. The shadcn primitives already do this; a + custom popover must too. +- **Overlays have a title.** Dialog, Sheet, Drawer always get a title; + `sr-only` when the design hides it. +- **Forms.** Every control has a connected label. Placeholder text is not a + label. Validation sets `aria-invalid` on the control and `data-invalid` on + the field. Errors read next to the field, not only in a toast. +- **Keyboard.** `⌘↵` submits a composer, `Escape` leaves it, a single letter + toggles a mode (`E` for preview). Shortcuts ignore key repeat, ignore + typing inside inputs, textareas and open overlays, and show in the tooltip. +- **Targets.** 24×24px minimum on desktop, 44×44px where touch is likely. + Pad the hit area, not the icon. +- **Loading.** `Skeleton` for layouts, `Spinner` with `disabled` on a button. + No `isLoading` prop on a primitive; compose it. +- **Destructive actions confirm** with a dialog that names the thing being + removed. The last item in a required list cannot be deleted; hide the + action rather than erroring. +- **Toasts** carry an error the user can act on. A failed background call is + never silent. + +## Responsive and mobile + +- Mobile first. Breakpoints follow the content, not devices. `sm:` for two + columns, `md:` for the full layout. A four-column grid steps through two. +- No horizontal page scroll at 320px. Tables overflow in their own scroller + (`overscroll-behavior: contain`); pages never do. +- `hover:` styles apply only where hover exists. Tailwind v4 compiles `hover:` + to `(hover: hover)`; do not reintroduce ungated hover with raw CSS. Touch + users get `:active` feedback instead. +- `touch-action: manipulation` on tappable elements removes the double-tap + delay. `-webkit-tap-highlight-color: transparent` globally, then every + tappable has its own `:active` state. +- Inputs are 16px on touch so iOS does not zoom. Never disable zoom. +- `100dvh` for app shells, drawers and bottom-pinned UI; `100svh` for heroes. + Fixed bars pad with `env(safe-area-inset-*)` and the viewport meta sets + `viewport-fit=cover`. +- Swap structure with `useIsMobile` only when CSS cannot: Dialog becomes + Drawer, a row of actions becomes a menu. Everything else is a breakpoint. +- Gate on capability (`(pointer: coarse)`, `(hover: hover)`), never on the + user agent. +- Test on a phone, not only in the emulator. Sticky hover, the keyboard and + safe areas do not reproduce. + +## HTML + +- Semantic first: `ol` for ordered timelines, `time` with `dateTime` for + every timestamp, `header`/`section`/`aside` where the page has those parts, + one `h1`, headings in order. +- Interactive inside interactive is a bug: no button in a link, no link in a + button. A card that is clickable has one link, overlaid, and the rest is + inert to the click. +- Images declare `width` and `height` or `fill`; no layout shift on load. +- `color-scheme: light dark` on the root; `theme-color` once per scheme. +- No manual `z-index` on overlays; the primitives own the stack. +- `inert` on background content while an overlay is open comes from the + primitive. Do not reimplement focus trapping. + +## Worst cases + +Before calling a view done, render it with: + +- a 255-character title, an email with no break opportunity, a one-letter + name, a name with emoji +- no avatar, no author (legacy rows), no description +- 0, 1 and 1,000 items; a count of 1 that must not say "1 updates" +- 320px wide, the widest layout, 200% zoom +- both themes + +Flex failures look like squished avatars (`shrink-0`), text that overflows +instead of wrapping (`min-w-0`), actions pushed off-screen, badges on two +lines, and a last row clipped mid-glyph. + +## Verify by looking + +An agent cannot judge rendering from a diff. For any visible change, +`scripts/screenshot.mjs` (the `screenshot` skill) shoots the element +(`[data-slot=timeline]`, not the viewport) in both themes on the local dev +server of the dashboard, the marketing site or the status page, logging in +with a seeded session where needed. Compare against the previous state. Feedback +goes back the same way: a screenshot with the problem boxed and one sentence. +Marketing and docs pages render every component on `/kitchen-sink` so drift +is visible there first. -- 2.54.0