Kortix Design System
Track this file: .claude/skills/kortix-design-system/SKILL.md
If you are touching a visual surface in apps/web, follow this. This file owns which component to compose. It does not own values — see below.
Read kortix-brand-guidelines first.
It is the value law: the closed allowlist for every color, spacing step, type
rung, radius, elevation, easing and duration, plus the rule on whether to
animate at all. This file assumes you already know those values and shows
you what to build with them.
Companion skills — load all three
| Skill |
Owns |
Wins when they conflict |
| kortix-brand-guidelines |
The value allowlists. Color, spacing, type, radius, elevation, motion budget. Rejected defaults. audit.sh. |
Values and motion restraint |
| kortix-design-system (this file) |
Components, layout shells, reference implementations, banned primitives |
Which component |
| make-interfaces-feel-better |
Polish: concentric radius, optical alignment, tabular nums, hit areas, font smoothing |
Polish detail |
A polish skill will always propose more motion than Kortix wants.
kortix-brand-guidelines is the ceiling all three operate under.
Errata — corrections to earlier versions of this file
- The elevation ladder described here through Aug 2026 does not exist. An
earlier revision claimed
@theme in globals.css defines four-sided soft
shadows and that shadow-* renders them. Verified false at HEAD:
grep -an "shadow-" apps/web/src/app/globals.css returns exactly one line,
--shadow-liquid-glass. shadow-* is stock Tailwind. The semantics
below (which step for which surface) still hold; the custom rendering does not.
- The house press value is
active:scale-[0.96] (169 uses), not 0.97
(21 uses).
Philosophy
- Simplicity is the brand. Black & white + one accent. Calm, spacious, legible. No decoration that doesn't carry information. Show only important data.
- Reuse > Compose > Create. In that order. Never hand-roll something the system already provides.
- Tokens are law.
apps/web/src/app/globals.css is the implementation source of truth for every visual property. If a value conflicts with anything else, globals.css wins.
- Fast, or none. Kortix is a speed product. Motion exists to make the UI feel faster and to say where a thing came from — never to be admired. The more you animate, the less each animation is worth. The default answer to "should this animate?" is no.
- AI-native & self-documenting. The living styleguide at
/design-system renders every component. When you add a component, add it there too.
Strictly avoid — deprecated primitives
Do not use these in new work or when refactoring screens. They are legacy wrappers; match the hand-composed patterns in the customize section views instead.
| Banned |
Use instead |
SectionCard (apps/web/src/components/ui/section-card.tsx) |
Card (card.tsx), Label + bg-popover rounded-md border panel, or Disclosure — see project-settings-page.tsx |
List / ListRow (apps/web/src/components/ui/list.tsx) |
<ul className="space-y-2"> + entity row classes — see changes-view.tsx, access-row.tsx |
Dialog / DialogContent in feature code |
Modal from apps/web/src/components/ui/modal.tsx — see secrets-view.tsx, channels-view.tsx |
Tooltip / TooltipTrigger / TooltipContent in feature code |
Hint from apps/web/src/components/ui/hint.tsx |
@/lib/toast, raw sonner, toast.custom() |
Named helpers from apps/web/src/components/ui/toast.tsx |
Hand-rolled badge <span> chips |
Badge from apps/web/src/components/ui/badge.tsx |
Any icon as a spinner — CircleNotchIcon, SpinnerIcon, SpinnerGapIcon, or the same glyph aliased to Loader/Loader2/IconLoader |
Loading from apps/web/src/components/ui/loading.tsx — the codebase's only spinner |
Hand-rolled <svg> spinners, animate-spin on non-Loading elements |
Loading — animation is built in |
When editing a file that already uses banned primitives, migrate to the reference pattern — do not add more usage.
Required primitives — use these, not alternatives
These are mandatory for their job. Import from the paths below; never reimplement or swap in a different library.
| Job |
Import from |
Notes |
| Tooltips on icon buttons |
apps/web/src/components/ui/hint.tsx |
<Hint label="…">…</Hint> — wraps trigger, never Tooltip in features |
| Dialogs / sheets |
apps/web/src/components/ui/modal.tsx |
Modal, ModalContent, ModalHeader, ModalTitle, ModalDescription, ModalBody, ModalFooter |
| Toasts |
apps/web/src/components/ui/toast.tsx |
successToast, errorToast, infoToast, warningToast, progressToast, loadingToast |
| Status chips |
apps/web/src/components/ui/badge.tsx |
size="sm" or size="xs"; variants outline, kortix, success, destructive, beta, etc. |
| Expand/collapse panels |
apps/web/src/components/ui/disclosure.tsx |
Disclosure, DisclosureTrigger, DisclosureContent — config lists, settings groups |
| Inline alerts |
apps/web/src/components/ui/info-banner.tsx |
tone + optional icon + title |
| Search fields |
apps/web/src/components/ui/input-group.tsx |
InputGroupSearch + InputGroupSearchInput variant="popover" |
| Forms in panels |
apps/web/src/components/ui/field.tsx |
Field, FieldLabel, FieldGroup, FieldDescription |
| Empty / error states |
apps/web/src/features/layout/section/empty-state.tsx, error-state.tsx |
size="sm" in customize sections |
| Confirm destructive |
apps/web/src/components/ui/confirm-dialog.tsx |
Mandatory before any destructive mutation — including DropdownMenuItem variant="destructive" items (see secrets-view.tsx delete, sections/view/gateway/gateway-keys.tsx revoke). Only accepted alternative: the inline Cancel/confirm button swap used for channel disconnects (channels-view.tsx). Never mutate from a single click |
| Loading / pending spinners |
apps/web/src/components/ui/loading.tsx |
import Loading from '@/components/ui/loading' — default size-4; use className="size-4 shrink-0" in dense buttons. Never CircleNotchIcon, SpinnerIcon, or other icons |
Also reach for: Button, ButtonGroup, Input, Select, Switch, Skeleton, Tabs / TabsListCompact, Table, InlineMeta, UserAvatar, EntityAvatar.
Reference implementations — customize section views
Read the closest match before building any new screen. Paths below are
relative to apps/web/src/features/workspace/ unless given in full.
| File |
Pattern to copy |
customize/sections/component/section-wrapper.tsx |
Section shell: title left, action right, max-w-2xl, responsive header |
capabilities/agents/agents-page.tsx |
Config entity list: search → rows → detail aside with Badge, ButtonGroup + Hint, toasts |
customize/sections/view/skills-view.tsx |
Disclosure config-entity flow; EmptyState + docs link; InfoBanner for 403 |
capabilities/project-settings/project-settings-page.tsx |
Form sections: Label header → bg-popover rounded-md border px-4 py-5 panel; Disclosure for experimental; danger zone as neutral bordered row |
customize/sections/view/secrets-view.tsx |
Table + TabsListCompact filters + Modal forms + DropdownMenu row actions |
shared/access/access-row.tsx |
Entity rows, UserAvatar, InlineMeta — the single row used by every access surface |
customize/sections/view/changes-view.tsx |
Tinted size-9 icon tiles, Badge variant="kortix" size="xs", row inline actions, TabsListCompact |
customize/sections/view/channels-view.tsx |
Table for integrations, Modal for connect flows, InfoBanner for connected state |
customize/sections/view/dev-view.tsx |
Stepper onboarding, command blocks, minimal bordered panels |
apps/web/src/features/tunnel/tunnel-overview.tsx |
Device/computer list; the former computers-view delegated to it |
Shells: capabilities/index/customize-index-page.tsx (the customize index)
and capabilities/shared/capability-page-shell.tsx (CapabilityPageShell) — the
current shell for capability pages. The older CustomizeSectionWrapper
(customize/sections/component/section-wrapper.tsx) still backs the
customize/sections/view/* screens.
Other references: tinted-icon tiles → apps/web/src/components/projects/schedule-view.tsx; sidebar → project-sidebar/project-sidebar.tsx.
Layout & responsiveness
Always wrap a section in the shell its tree already uses — CapabilityPageShell
under capabilities/, CustomizeSectionWrapper under customize/sections/view/.
Do not hand-roll the outer shell.
Canonical pattern (from section-wrapper.tsx):
<div className="flex h-full min-h-0 flex-col">
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-2xl space-y-5 px-4 py-10 pb-20 lg:py-20">
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<h2 className="text-foreground text-xl font-medium">{title}</h2>
<span className="flex items-center gap-1">
<p className="text-muted-foreground text-sm text-balance">{description}</p>
{/* optional docs link: Button variant="transparent" asChild */}
</span>
</div>
{action ? <div className="mt-2 shrink-0 sm:mt-0">{action}</div> : null}
</header>
{children}
</div>
</div>
</div>
Rules:
- Header: title + description left; primary action right. Stacks on mobile (
flex-col sm:flex-row). Content always below the header.
- Container:
mx-auto w-full max-w-2xl.
- Section padding:
py-10 pb-20 lg:py-20; space-y-5 between header and body.
- Major blocks inside body:
space-y-4 (search + list), space-y-6 (tab panels), space-y-8 (settings sections).
- Mobile-first: test narrow width.
Card & panel patterns (no SectionCard)
Every panel is a bg-popover rounded-md border surface. Two sanctioned ways to build one — never SectionCard:
Card (apps/web/src/components/ui/card.tsx) — the codified panel. Use it when the surface has a title/description/action header, distinct content, or a footer. Slots carry the spacing (px-4, pt-5/pb-5, gap-5 between slots — the panel px-4 py-5 rhythm); the bordered element itself has no padding, so flush children (tables, lists, images) sit edge-to-edge.
- Hand-composed
div — for a one-off padded block inside a section, the one-div shorthand bg-popover rounded-md border px-4 py-5 is fine (the project-settings-page.tsx pattern). The moment the panel needs a flush child or an internal seam, move the padding onto inner sections.
Panels are flat: border, no shadow — elevation is for overlays (see Elevation under Tokens).
Card (component)
<Card>
<CardHeader>
<CardTitle>Repository</CardTitle>
<CardDescription>Where builds are pushed.</CardDescription>
<CardAction>
<Button size="sm" variant="secondary">Edit</Button>
</CardAction>
</CardHeader>
<CardContent>{/* fields, rows — or a flush table as a direct Card child */}</CardContent>
<CardFooter className="border-t">{/* meta or trailing actions */}</CardFooter>
</Card>
CardTitle is text-sm font-medium — panel-label scale, same as Label. Don't scale it up; a Card is a panel, not a hero.
- Divided header:
<CardHeader className="border-b"> (it compacts to pb-4); divided footer: className="border-t".
variant="glass" (bg-card/40 border-border/40 shadow-sm) is the only elevated variant — translucent surfaces over wallpaper/media.
Settings / form panel
<section className="space-y-4">
<Label>Repository</Label>
<div className="bg-popover space-y-5 rounded-md border px-4 py-5">
<FieldGroup className="grid gap-3 sm:grid-cols-2">{/* fields */}</FieldGroup>
</div>
</section>
Entity row (list item)
<ul className="space-y-2">
<li className="group bg-popover flex items-center gap-3 rounded-md border px-4 py-2 transition-colors">
{/* leading: size-9 tinted icon tile */}
{/* body: min-w-0 flex-1 */}
{/* trailing: Button size="sm" */}
</li>
</ul>
Access rows use py-2.5 (shared/access/access-row.tsx). Changes use py-2.
Config entity disclosure (agents, skills, commands)
<div className="space-y-2">
<Disclosure variant="outline" className="overflow-hidden" open={open}
<DisclosureTrigger variant="outline">
<Button variant="popover" className="flex w-full items-center justify-start rounded-none">
<span className="truncate text-sm font-medium">{name}</span>
</Button>
</DisclosureTrigger>
<DisclosureContent variant="outline" contentClassName="border-border border-t">
<div className="relative px-4 py-5">{/* detail */}</div>
</DisclosureContent>
</Disclosure>
</div>
Detail header: text-2xl font-semibold tracking-tight title; meta Badge variant="outline" size="sm"; toolbar absolute top-4 right-4 with ButtonGroup + Hint.
Danger zone (settings)
Neutral bordered row — no red panel fill:
<div className="bg-popover rounded-md border px-4 py-3">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">{/* title text-sm font-medium + description text-xs */}</div>
<Button variant="destructive" size="sm"
</div>
</div>
destructive on the button is OK inside ConfirmDialog flow; panel itself stays neutral.
Button conventions
Match the customize views — consistent sizes and variants:
| Context |
Pattern |
| Section header primary action |
Button size="sm" variant="secondary" + PlusIcon (size-4) + label; group with gap-1.5 |
| Empty state CTA |
Button variant="outline" size="sm" className="gap-1.5" |
| Docs / secondary link |
Button asChild variant="ghost" size="sm" className="gap-1.5" |
| Row secondary action |
Button variant="ghost" size="sm" |
| Row primary action |
Button size="sm" (default variant) |
| Icon-only with tooltip |
Hint → Button variant="outline" size="icon" inside ButtonGroup |
| Inline text link |
Button variant="transparent" size="sm" asChild |
| Modal cancel |
Button variant="outline-ghost" |
| Pending / in-flight state |
<Loading className="size-4 shrink-0" /> in buttons; <Loading /> or className="size-4 shrink-0" in headers — never CircleNotchIcon |
Icons in buttons: size-3.5 shrink-0 (dense) or size-4 (header). Always shrink-0 on icons. Exception: loading uses Loading, not an icon import.
Icons — Phosphor only
The only icon library in apps/web is @phosphor-icons/react. lucide-react,
react-icons, @mynaui/icons-react, and @icons-pack/react-simple-icons are
removed and blocked by ESLint (no-restricted-imports).
- Always import the
*Icon-suffixed exports: PlusIcon, MagnifyingGlassIcon,
CaretRightIcon — never the deprecated bare names (Plus).
- Never pass a
weight prop. One constant governs the whole app:
DEFAULT_ICON_WEIGHT in src/lib/icons/icon-config.ts (currently bold).
Change it, save, and every icon flips; commit it and production renders the
same. There is no runtime toggle and no dev/prod branching — the constant
is the single source of truth in every environment. Compare all six weights
at /design-system → Icons.
- Two delivery paths, same constant, both propless:
- Client components →
IconProvider (root layout) feeds it to Phosphor's
IconContext. Import from @phosphor-icons/react.
- Server components (RSC) → React context does not exist in RSC, so import
from
@/lib/icons/ssr, where the weight is pre-bound. Importing
@phosphor-icons/react/dist/ssr directly is ESLint-blocked (it silently
defaults to regular); importing the main entry in a server component
crashes the build (createContext at module scope). Adding an icon to a
server component? Add its two lines to src/lib/icons/ssr.tsx.
- Exception — solid intent: status tiles, success checks, destructive
trash, and logo glyphs pass an explicit
weight="fill" so they stay solid
regardless of the global weight. This is the only weight prop in the codebase.
- Semantic layer:
src/components/ui/kortix-icons.ts (IconAdd, IconDelete,
…) re-exports Phosphor icons; prefer it where already adopted. It re-exports
the client entry, so it is client-graph only.
- Sizing stays Tailwind-first (
size-4, size-3.5 shrink-0 in dense buttons);
the provider's size: 24 default only covers class-less usages.
- Background on the RSC rule above: the main entry calls
createContext at
module scope with no 'use client' directive, so a server-only module graph
reaching it crashes the build with
TypeError: (0, d.createContext) is not a function. Phosphor's own SSR entry
fixes the crash but hardcodes weight="regular", which would quietly ignore
DEFAULT_ICON_WEIGHT — that is why @/lib/icons/ssr exists and why the raw
entry is ESLint-blocked outside it.
Motion in components
Budget and easings live in kortix-brand-guidelines → Motion.
Read the frequency ladder there before adding any animation. The short version:
Kortix is a speed product. Fast, or none. Count how many times a day a user
sees the interaction; that number sets the budget. Constant-use surfaces —
keyboard nav, dense-list hover, focus moves, table selection — get
no animation at all.
What that means for the components in this file:
| Component |
Motion |
| Row hover in a list, sidebar nav |
transition-colors duration-fast. Color only — never position. |
| Keyboard-driven selection / arrow-key nav |
None. transition-none. |
Disclosure, Tabs |
200ms, ease-out. Built in — do not add more. |
Modal, Dialog, sheets |
300ms max, ease-out, enter from scale-0.97. Built into modal.tsx. |
| Toast |
Owned by toast.tsx. Do not restyle its motion. |
Loading |
Ships its own rotation. Never add animate-spin. |
| Buttons |
active:scale-[0.96] + transition-colors. Nothing else. |
| Skeleton → content |
Cross-fade opacity, or nothing. Never a stagger. |
Do not add enter animations to list items. A staggered or animated list is
measurably slower to read and to click than the same list appearing at once, and
the cost is paid on every render.
Sanctioned exception — the icon-swap morph
Frequency gate: this pattern is for buttons whose state change is meaningful
and occasional — copy → copied, connect → connected, follow → following. It is
not for a control the user hits repeatedly in a working session. If in doubt,
hard-swap the icon; a hard swap is never wrong, it is only plain.
When it does apply: never hard-swap {done ? <CheckIcon/> : <CopyIcon/>} with no
transition. Cross-fade the two icons in the same box with blur + scale +
opacity so it reads as one morph, not two objects blinking. The blur is what
bridges the two states — without it the eye sees two distinct objects.
Values: scale 0.25 → 1, opacity 0 → 1, blur 4px → 0, spring
{ type: 'spring', duration: 0.3, bounce: 0 }. bounce: 0 is the brand.
Always initial={false} so nothing animates on first paint.
import { AnimatePresence, motion } from 'motion/react';
<button
aria-label={copied ? 'Copied' : 'Copy'}
className={cn(
'inline-flex size-7 items-center justify-center rounded-md',
'text-muted-foreground hover:text-foreground hover:bg-muted-foreground/10',
'cursor-pointer transition-colors active:scale-[0.96]',
)}
>
<span className="relative inline-flex size-3.5 items-center justify-center">
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={copied ? 'check' : 'copy'}
initial={{ scale: 0.25, opacity: 0, filter: 'blur(4px)' }}
animate={{ scale: 1, opacity: 1, filter: 'blur(0px)' }}
exit={{ scale: 0.25, opacity: 0, filter: 'blur(4px)' }}
transition={{ type: 'spring', duration: 0.3, bounce: 0 }}
className="absolute inset-0 inline-flex items-center justify-center"
>
{copied ? <CheckIcon className="size-3.5 text-kortix-green" /> : <CopyIcon className="size-3.5" />}
</motion.span>
</AnimatePresence>
</span>
</button>
Rules: both icons share one fixed-size box (relative size-3.5 parent, each
child absolute inset-0) so they overlap and the blur bridges the crossfade.
Confirmed status colour stays a kortix-* token (text-kortix-green), never raw
palette. Ship a prefers-reduced-motion branch — drop the scale and blur, keep
the opacity. Reference: CopyButton in
apps/web/src/components/markdown/copy-button.tsx.
Spacing cheat sheet (from reference views)
| Layer |
Classes |
| Section wrapper → body |
space-y-5 |
| Search + content block |
space-y-4 |
| List of rows / disclosures |
space-y-2 inside space-y-4 parent |
| Settings major sections |
space-y-8 |
| Tab panel content |
space-y-6 |
| Panel inner padding |
px-4 py-5 (standard), px-4 py-3 (compact row) |
| Row internal gap |
gap-3 (row), gap-1.5 (title/meta), gap-2 (button groups) |
| Detail content below title |
mt-8 |
| No-match empty search |
px-3 py-6 text-center text-xs |
Card slot rhythm |
px-4 all slots; pt-5 header / pb-5 last slot; gap-5 between slots |
Padding never sits on a bordered element that hosts flush children (tables, lists, seams) — put it on the slots/inner sections. A single padded block may use the one-div shorthand bg-popover rounded-md border px-4 py-5.
Tokens — see kortix-brand-guidelines
Every value lives in kortix-brand-guidelines.
Colors, spacing steps, type rungs, radius ladder, elevation steps, easings and
durations are enumerated there as closed allowlists, with the reasons and the
audit.sh that enforces them. This file used to restate them; two copies drift,
so the copy is gone.
The five facts you will get wrong from stock Tailwind habit, restated here only
because they bite immediately:
| Fact |
Consequence |
--spacing: 0.23rem, not 0.25rem |
Every spacing utility is 8% tighter. A 16px mockup padding is p-4, never p-[16px]. |
--text-xs: 0.8125rem = 13px, not 12px |
text-xs is the workhorse (1999 uses). There is no rung below it. |
--radius: 0.625rem → rounded-md is 8px |
rounded-md is the default for every panel, row, table and card. |
Color is semantic tokens + the seven kortix-* accents |
No raw palette, no hex, and no dark: for color — tokens flip themselves. |
| In-flow surfaces are flat — border, no shadow |
Shadows mean "floats above the page". |
Status pattern — tinted icon tile
<span className={cn(
'flex size-9 items-center justify-center rounded-sm',
merged && 'bg-kortix-green/15',
failed && 'bg-kortix-red/15',
open && 'bg-kortix-blue/15',
)}>
<Icon className={cn('size-5', merged && 'text-kortix-green', …)} />
</span>
Use weight="fill" icons at size-5 inside size-8/size-9 tiles. Pair with Badge for text labels when needed (changes-view.tsx, sandbox-provider-coverage.tsx).
Modal pattern (canonical — use modal.tsx)
From secrets-view.tsx / channels-view.tsx — not raw Dialog:
<Modal open={open}
<ModalContent className="lg:max-w-lg">
<ModalHeader>
<ModalTitle>Title</ModalTitle>
<ModalDescription>Description</ModalDescription>
</ModalHeader>
<form
<ModalBody className="max-h-[60vh] overflow-y-auto">
{/* fields */}
</ModalBody>
<ModalFooter className="sm:justify-between">
<Button type="button" variant="outline-ghost" => setOpen(false)}>
Cancel
</Button>
<Button type="submit" disabled={pending}>
{pending ? <Loading className="size-4 shrink-0" /> : null}
Save
</Button>
</ModalFooter>
</form>
</ModalContent>
</Modal>
Destructive confirms → ConfirmDialog, not a red-styled Modal trigger.
Tabs pattern
- Primary section tabs:
TabsList type="underline" + TabsTrigger className="w-fit flex-none" (changes-view.tsx, channels-view.tsx)
- Filter / status tabs:
TabsListCompact + TabsTriggerCompact (changes-view.tsx, secrets-view.tsx)
- Tab badge count:
<Badge variant="secondary" size="sm"> inside trigger
Loading pattern (canonical)
Every in-flight spinner is Loading from loading.tsx. There are no exceptions and no second spinner in this codebase. The component ships its own rotate/dash animation, so it never needs animate-spin.
No icon is ever a spinner. Not CircleNotchIcon, not SpinnerIcon, not SpinnerGapIcon, not any other Phosphor glyph, whatever it is aliased to (Loader, Loader2, IconLoader, …). If you catch yourself writing animate-spin on an icon, the answer is Loading. kortix-icons.ts deliberately exports no loader icon so the semantic layer can't offer you one.
import Loading from '@/components/ui/loading';
// Button pending (replaces action icon)
<Button disabled={pending}>
{pending ? <Loading className="size-3.5 shrink-0" /> : <PlusIcon className="size-3.5 shrink-0" />}
Save
</Button>
// Section header action
<Button size="sm" variant="secondary" disabled={pending}>
{pending ? <Loading className="size-4 shrink-0" /> : <PlusIcon className="size-4" />}
New
</Button>
// Inline / modal submit
{pending ? <Loading className="size-4 shrink-0" /> : null}
For page-level loading placeholders use Skeleton (shape-matched). Use Loading only for active async operations (submit, fetch-in-button, mutation pending).
Search + loading + empty flow
Standard content block (agents-page.tsx pattern):
<div className="space-y-4">
<InputGroupSearch>…<InputGroupSearchInput variant="popover" />…</InputGroupSearch>
{isLoading ? (
<div className="space-y-1">{/* Skeleton h-7 rounded-md × 5 */}</div>
) : isError ? (
<ErrorState size="sm" action={<Button variant="outline" size="sm">Retry</Button>} />
) : items.length === 0 ? (
<EmptyState icon={…} size="sm" action={…} />
) : (
/* list */
)}
</div>
Dos & Don'ts
- ✅ Section shell →
CustomizeSectionWrapper. ❌ hand-rolled outer flex + header.
- ✅ Panels →
Card (card.tsx) or bg-popover rounded-md border with px-4 py-5. ❌ SectionCard, ❌ padding on a bordered element that hosts flush children.
- ✅ Elevation → ladder step (
shadow-md popovers, shadow-lg modals); in-flow panels stay flat with a border. ❌ dark:shadow-*, ❌ shadow-[…] when a step fits.
- ✅ Lists →
<ul className="space-y-2"> + entity row classes. ❌ List / ListRow, ❌ divide-y Card lists.
- ✅ Expandable config →
Disclosure + Button variant="popover". ❌ custom accordion, ❌ nested rounded-md inside rounded parent.
- ✅ Modals →
Modal from modal.tsx. ❌ Dialog/DialogContent in features.
- ✅ Destructive actions →
ConfirmDialog (or the inline two-step Cancel/confirm swap, channels-view.tsx). ❌ firing a delete/revoke mutation directly from a variant="destructive" click.
- ✅ Tooltips →
Hint. ❌ Tooltip primitives in features.
- ✅ Toasts →
@/components/ui/toast helpers. ❌ @/lib/toast, raw sonner.
- ✅ Badges →
<Badge size="sm" variant="…">. ❌ hand-rolled chip spans.
- ✅ Status → tinted icon tile + optional
Badge. ❌ raw palette icon colors.
- ✅ Color →
kortix-* + semantic tokens. ❌ text-emerald-600, bg-amber-500.
- ✅ Meta separators →
InlineMeta or text-muted-foreground/40 bullet (•). ❌ inconsistent separators.
- ✅ Empty →
EmptyState. ❌ centered <p> only.
- ✅ Alerts →
InfoBanner. ❌ hand-rolled colored banners.
- ✅ Pending spinners →
Loading from loading.tsx. ❌ CircleNotchIcon, SpinnerIcon, or any animate-spin icon.
- ✅ Motion → count the frequency first;
transition-colors duration-fast on hover, ease-out on enter/exit, active:scale-[0.96] on press. ❌ transition-all, ❌ bare transition, ❌ ease-in, ❌ anything over 300ms in product UI.
- ✅ Keyboard-driven interactions →
transition-none. ❌ animating arrow-key nav or focus moves.
- ✅ Every animation ships a
prefers-reduced-motion variant. ❌ one variant only.
- ✅ List items appear at once. ❌ stagger in product UI.
Workflow checklist
- Load
kortix-brand-guidelines — the value allowlists and the motion budget. Then this file, then make-interfaces-feel-better.
- Read the closest reference view from the table above. Copy structure, spacing, and primitives — don't invent a new layout dialect.
- Skim
/design-system and src/components/ui/ for anything not covered by the reference.
- Compose:
CustomizeSectionWrapper → search/panel/row/disclosure/table → Badge + Hint + Modal + toast + Loading + EmptyState. Never SectionCard, List, or CircleNotchIcon.
- Status → tinted icon tile. Color →
kortix-*. Radius → rounded-md (panel), rounded-none (flush trigger). Elevation → overlays only; flat border for in-flow panels.
- Motion last. Count how often a user sees it. Constant → none. Otherwise ≤300ms,
ease-out, named property, token duration, reduced-motion variant.
- New primitive? Tokens only, tiny API, add to
/design-system.
- Verify:
.claude/skills/kortix-brand-guidelines/audit.sh <your paths> clean, no banned imports, no nested rounding, light + dark, tsc clean, polish checklist from make-interfaces-feel-better.
- Replay the interaction ten times. If you still notice the animation, it is too much.
1---2name: kortix-design-system3description: Use when building or editing any visual surface in apps/web — a page, screen, list, card, badge, avatar, modal, form, empty state, toast, tooltip, table, or panel — and you need to know WHICH component to compose and which primitives are banned. Load kortix-brand-guidelines first for the values (color, spacing, type, radius, motion budget); this file assumes those and covers components, layout shells, and reference implementations. Source of truth: src/components/ui, the live /design-system page, and the reference implementations listed inside.4---56# Kortix Design System78**Track this file:** `.claude/skills/kortix-design-system/SKILL.md`910**If you are touching a visual surface in `apps/web`, follow this.** This file owns **which component to compose**. It does not own values — see below.1112> **Read [`kortix-brand-guidelines`](../kortix-brand-guidelines/SKILL.md) first.**13> It is the value law: the closed allowlist for every color, spacing step, type14> rung, radius, elevation, easing and duration, plus the rule on **whether to15> animate at all**. This file assumes you already know those values and shows16> you what to build with them.1718## Companion skills — load all three1920| Skill | Owns | Wins when they conflict |21| --- | --- | --- |22| [**kortix-brand-guidelines**](../kortix-brand-guidelines/SKILL.md) | The value allowlists. Color, spacing, type, radius, elevation, motion budget. Rejected defaults. `audit.sh`. | **Values and motion restraint** |23| **kortix-design-system** (this file) | Components, layout shells, reference implementations, banned primitives | **Which component** |24| [**make-interfaces-feel-better**](../../../apps/web/.agents/skills/make-interfaces-feel-better/SKILL.md) | Polish: concentric radius, optical alignment, tabular nums, hit areas, font smoothing | **Polish detail** |2526A polish skill will always propose more motion than Kortix wants.27`kortix-brand-guidelines` is the ceiling all three operate under.2829## Errata — corrections to earlier versions of this file3031- **The elevation ladder described here through Aug 2026 does not exist.** An32 earlier revision claimed `@theme` in `globals.css` defines four-sided soft33 shadows and that `shadow-*` renders them. Verified false at HEAD:34 `grep -an "shadow-" apps/web/src/app/globals.css` returns exactly one line,35 `--shadow-liquid-glass`. `shadow-*` is **stock Tailwind**. The *semantics*36 below (which step for which surface) still hold; the custom rendering does not.37- **The house press value is `active:scale-[0.96]`** (169 uses), not `0.97`38 (21 uses).3940## Philosophy4142- **Simplicity is the brand.** Black & white + one accent. Calm, spacious, legible. No decoration that doesn't carry information. Show only important data.43- **Reuse > Compose > Create.** In that order. Never hand-roll something the system already provides.44- **Tokens are law.** `apps/web/src/app/globals.css` is the implementation source of truth for every visual property. If a value conflicts with anything else, `globals.css` wins.45- **Fast, or none.** Kortix is a speed product. Motion exists to make the UI feel *faster* and to say where a thing came from — never to be admired. The more you animate, the less each animation is worth. The default answer to "should this animate?" is **no**.46- **AI-native & self-documenting.** The living styleguide at `/design-system` renders every component. When you add a component, add it there too.4748## Strictly avoid — deprecated primitives4950**Do not use these in new work or when refactoring screens.** They are legacy wrappers; match the hand-composed patterns in the customize section views instead.5152| Banned | Use instead |53| --- | --- |54| **`SectionCard`** (`apps/web/src/components/ui/section-card.tsx`) | `Card` (`card.tsx`), `Label` + `bg-popover rounded-md border` panel, or `Disclosure` — see `project-settings-page.tsx` |55| **`List` / `ListRow`** (`apps/web/src/components/ui/list.tsx`) | `<ul className="space-y-2">` + entity row classes — see `changes-view.tsx`, `access-row.tsx` |56| **`Dialog` / `DialogContent`** in feature code | **`Modal`** from `apps/web/src/components/ui/modal.tsx` — see `secrets-view.tsx`, `channels-view.tsx` |57| **`Tooltip` / `TooltipTrigger` / `TooltipContent`** in feature code | **`Hint`** from `apps/web/src/components/ui/hint.tsx` |58| **`@/lib/toast`**, raw `sonner`, `toast.custom()` | Named helpers from `apps/web/src/components/ui/toast.tsx` |59| Hand-rolled badge `<span>` chips | **`Badge`** from `apps/web/src/components/ui/badge.tsx` |60| **Any icon as a spinner** — `CircleNotchIcon`, `SpinnerIcon`, `SpinnerGapIcon`, or the same glyph aliased to `Loader`/`Loader2`/`IconLoader` | **`Loading`** from `apps/web/src/components/ui/loading.tsx` — the codebase's only spinner |61| Hand-rolled `<svg>` spinners, `animate-spin` on non-`Loading` elements | **`Loading`** — animation is built in |6263When editing a file that already uses banned primitives, migrate to the reference pattern — do not add more usage.6465## Required primitives — use these, not alternatives6667These are **mandatory** for their job. Import from the paths below; never reimplement or swap in a different library.6869| Job | Import from | Notes |70| --- | --- | --- |71| Tooltips on icon buttons | `apps/web/src/components/ui/hint.tsx` | `<Hint label="…">…</Hint>` — wraps trigger, never Tooltip in features |72| Dialogs / sheets | `apps/web/src/components/ui/modal.tsx` | `Modal`, `ModalContent`, `ModalHeader`, `ModalTitle`, `ModalDescription`, `ModalBody`, `ModalFooter` |73| Toasts | `apps/web/src/components/ui/toast.tsx` | `successToast`, `errorToast`, `infoToast`, `warningToast`, `progressToast`, `loadingToast` |74| Status chips | `apps/web/src/components/ui/badge.tsx` | `size="sm"` or `size="xs"`; variants `outline`, `kortix`, `success`, `destructive`, `beta`, etc. |75| Expand/collapse panels | `apps/web/src/components/ui/disclosure.tsx` | `Disclosure`, `DisclosureTrigger`, `DisclosureContent` — config lists, settings groups |76| Inline alerts | `apps/web/src/components/ui/info-banner.tsx` | `tone` + optional `icon` + `title` |77| Search fields | `apps/web/src/components/ui/input-group.tsx` | `InputGroupSearch` + `InputGroupSearchInput variant="popover"` |78| Forms in panels | `apps/web/src/components/ui/field.tsx` | `Field`, `FieldLabel`, `FieldGroup`, `FieldDescription` |79| Empty / error states | `apps/web/src/features/layout/section/empty-state.tsx`, `error-state.tsx` | `size="sm"` in customize sections |80| Confirm destructive | `apps/web/src/components/ui/confirm-dialog.tsx` | **Mandatory before any destructive mutation** — including `DropdownMenuItem variant="destructive"` items (see `secrets-view.tsx` delete, `sections/view/gateway/gateway-keys.tsx` revoke). Only accepted alternative: the inline Cancel/confirm button swap used for channel disconnects (`channels-view.tsx`). Never mutate from a single click |81| Loading / pending spinners | `apps/web/src/components/ui/loading.tsx` | `import Loading from '@/components/ui/loading'` — default `size-4`; use `className="size-4 shrink-0"` in dense buttons. **Never** `CircleNotchIcon`, `SpinnerIcon`, or other icons |8283Also reach for: `Button`, `ButtonGroup`, `Input`, `Select`, `Switch`, `Skeleton`, `Tabs` / `TabsListCompact`, `Table`, `InlineMeta`, `UserAvatar`, `EntityAvatar`.8485## Reference implementations — customize section views8687**Read the closest match before building any new screen.** Paths below are88relative to `apps/web/src/features/workspace/` unless given in full.8990| File | Pattern to copy |91| --- | --- |92| **`customize/sections/component/section-wrapper.tsx`** | Section shell: title left, action right, `max-w-2xl`, responsive header |93| **`capabilities/agents/agents-page.tsx`** | Config entity list: search → rows → detail aside with `Badge`, `ButtonGroup` + `Hint`, toasts |94| **`customize/sections/view/skills-view.tsx`** | Disclosure config-entity flow; `EmptyState` + docs link; `InfoBanner` for 403 |95| **`capabilities/project-settings/project-settings-page.tsx`** | Form sections: `Label` header → `bg-popover rounded-md border px-4 py-5` panel; `Disclosure` for experimental; danger zone as neutral bordered row |96| **`customize/sections/view/secrets-view.tsx`** | `Table` + `TabsListCompact` filters + **`Modal`** forms + `DropdownMenu` row actions |97| **`shared/access/access-row.tsx`** | Entity rows, `UserAvatar`, `InlineMeta` — the single row used by every access surface |98| **`customize/sections/view/changes-view.tsx`** | Tinted `size-9` icon tiles, `Badge variant="kortix" size="xs"`, row inline actions, `TabsListCompact` |99| **`customize/sections/view/channels-view.tsx`** | `Table` for integrations, `Modal` for connect flows, `InfoBanner` for connected state |100| **`customize/sections/view/dev-view.tsx`** | `Stepper` onboarding, command blocks, minimal bordered panels |101| **`apps/web/src/features/tunnel/tunnel-overview.tsx`** | Device/computer list; the former `computers-view` delegated to it |102103**Shells:** `capabilities/index/customize-index-page.tsx` (the customize index)104and `capabilities/shared/capability-page-shell.tsx` (`CapabilityPageShell`) — the105current shell for capability pages. The older `CustomizeSectionWrapper`106(`customize/sections/component/section-wrapper.tsx`) still backs the107`customize/sections/view/*` screens.108109**Other references:** tinted-icon tiles → `apps/web/src/components/projects/schedule-view.tsx`; sidebar → `project-sidebar/project-sidebar.tsx`.110111## Layout & responsiveness112113**Always wrap a section in the shell its tree already uses** — `CapabilityPageShell`114under `capabilities/`, `CustomizeSectionWrapper` under `customize/sections/view/`.115Do not hand-roll the outer shell.116117Canonical pattern (from `section-wrapper.tsx`):118119```tsx120<div className="flex h-full min-h-0 flex-col">121 <div className="min-h-0 flex-1 overflow-y-auto">122 <div className="mx-auto w-full max-w-2xl space-y-5 px-4 py-10 pb-20 lg:py-20">123 <header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">124 <div className="space-y-1">125 <h2 className="text-foreground text-xl font-medium">{title}</h2>126 <span className="flex items-center gap-1">127 <p className="text-muted-foreground text-sm text-balance">{description}</p>128 {/* optional docs link: Button variant="transparent" asChild */}129 </span>130 </div>131 {action ? <div className="mt-2 shrink-0 sm:mt-0">{action}</div> : null}132 </header>133 {children}134 </div>135 </div>136</div>137```138139Rules:140141- **Header:** title + description left; primary action right. Stacks on mobile (`flex-col sm:flex-row`). Content always **below** the header.142- **Container:** `mx-auto w-full max-w-2xl`.143- **Section padding:** `py-10 pb-20 lg:py-20`; `space-y-5` between header and body.144- **Major blocks inside body:** `space-y-4` (search + list), `space-y-6` (tab panels), `space-y-8` (settings sections).145- Mobile-first: test narrow width.146147## Card & panel patterns (no SectionCard)148149Every panel is a `bg-popover rounded-md border` surface. Two sanctioned ways to build one — never `SectionCard`:150151- **`Card`** (`apps/web/src/components/ui/card.tsx`) — the codified panel. Use it when the surface has a title/description/action header, distinct content, or a footer. Slots carry the spacing (`px-4`, `pt-5`/`pb-5`, `gap-5` between slots — the panel `px-4 py-5` rhythm); the bordered element itself has **no padding**, so flush children (tables, lists, images) sit edge-to-edge.152- **Hand-composed `div`** — for a one-off padded block inside a section, the one-div shorthand `bg-popover rounded-md border px-4 py-5` is fine (the `project-settings-page.tsx` pattern). The moment the panel needs a flush child or an internal seam, move the padding onto inner sections.153154Panels are **flat**: border, no shadow — elevation is for overlays (see *Elevation* under Tokens).155156### Card (component)157158```tsx159<Card>160 <CardHeader>161 <CardTitle>Repository</CardTitle>162 <CardDescription>Where builds are pushed.</CardDescription>163 <CardAction>164 <Button size="sm" variant="secondary">Edit</Button>165 </CardAction>166 </CardHeader>167 <CardContent>{/* fields, rows — or a flush table as a direct Card child */}</CardContent>168 <CardFooter className="border-t">{/* meta or trailing actions */}</CardFooter>169</Card>170```171172- `CardTitle` is `text-sm font-medium` — panel-label scale, same as `Label`. Don't scale it up; a Card is a panel, not a hero.173- Divided header: `<CardHeader className="border-b">` (it compacts to `pb-4`); divided footer: `className="border-t"`.174- `variant="glass"` (`bg-card/40 border-border/40 shadow-sm`) is the only elevated variant — translucent surfaces over wallpaper/media.175176### Settings / form panel177178```tsx179<section className="space-y-4">180 <Label>Repository</Label>181 <div className="bg-popover space-y-5 rounded-md border px-4 py-5">182 <FieldGroup className="grid gap-3 sm:grid-cols-2">{/* fields */}</FieldGroup>183 </div>184</section>185```186187### Entity row (list item)188189```tsx190<ul className="space-y-2">191 <li className="group bg-popover flex items-center gap-3 rounded-md border px-4 py-2 transition-colors">192 {/* leading: size-9 tinted icon tile */}193 {/* body: min-w-0 flex-1 */}194 {/* trailing: Button size="sm" */}195 </li>196</ul>197```198199Access rows use `py-2.5` (`shared/access/access-row.tsx`). Changes use `py-2`.200201### Config entity disclosure (agents, skills, commands)202203```tsx204<div className="space-y-2">205 <Disclosure variant="outline" className="overflow-hidden" open={open} onOpenChange={setOpen}>206 <DisclosureTrigger variant="outline">207 <Button variant="popover" className="flex w-full items-center justify-start rounded-none">208 <span className="truncate text-sm font-medium">{name}</span>209 </Button>210 </DisclosureTrigger>211 <DisclosureContent variant="outline" contentClassName="border-border border-t">212 <div className="relative px-4 py-5">{/* detail */}</div>213 </DisclosureContent>214 </Disclosure>215</div>216```217218Detail header: `text-2xl font-semibold tracking-tight` title; meta `Badge variant="outline" size="sm"`; toolbar `absolute top-4 right-4` with `ButtonGroup` + `Hint`.219220### Danger zone (settings)221222Neutral bordered row — no red panel fill:223224```tsx225<div className="bg-popover rounded-md border px-4 py-3">226 <div className="flex items-center justify-between gap-4">227 <div className="min-w-0">{/* title text-sm font-medium + description text-xs */}</div>228 <Button variant="destructive" size="sm" onClick={openConfirm}>Archive</Button>229 </div>230</div>231```232233`destructive` on the button is OK inside `ConfirmDialog` flow; panel itself stays neutral.234235## Button conventions236237Match the customize views — consistent sizes and variants:238239| Context | Pattern |240| --- | --- |241| Section header primary action | `Button size="sm" variant="secondary"` + `PlusIcon` (`size-4`) + label; group with `gap-1.5` |242| Empty state CTA | `Button variant="outline" size="sm" className="gap-1.5"` |243| Docs / secondary link | `Button asChild variant="ghost" size="sm" className="gap-1.5"` |244| Row secondary action | `Button variant="ghost" size="sm"` |245| Row primary action | `Button size="sm"` (default variant) |246| Icon-only with tooltip | `Hint` → `Button variant="outline" size="icon"` inside `ButtonGroup` |247| Inline text link | `Button variant="transparent" size="sm" asChild` |248| Modal cancel | `Button variant="outline-ghost"` |249| Pending / in-flight state | `<Loading className="size-4 shrink-0" />` in buttons; `<Loading />` or `className="size-4 shrink-0"` in headers — **never** `CircleNotchIcon` |250251Icons in buttons: `size-3.5 shrink-0` (dense) or `size-4` (header). Always `shrink-0` on icons. **Exception:** loading uses `Loading`, not an icon import.252253## Icons — Phosphor only254255**The only icon library in apps/web is `@phosphor-icons/react`.** `lucide-react`,256`react-icons`, `@mynaui/icons-react`, and `@icons-pack/react-simple-icons` are257removed and blocked by ESLint (`no-restricted-imports`).258259- Always import the `*Icon`-suffixed exports: `PlusIcon`, `MagnifyingGlassIcon`,260 `CaretRightIcon` — never the deprecated bare names (`Plus`).261- **Never pass a `weight` prop.** One constant governs the whole app:262 `DEFAULT_ICON_WEIGHT` in `src/lib/icons/icon-config.ts` (currently `bold`).263 Change it, save, and every icon flips; commit it and production renders the264 same. There is no runtime toggle and **no dev/prod branching** — the constant265 is the single source of truth in every environment. Compare all six weights266 at /design-system → Icons.267- **Two delivery paths, same constant, both propless:**268 - *Client components* → `IconProvider` (root layout) feeds it to Phosphor's269 `IconContext`. Import from `@phosphor-icons/react`.270 - *Server components (RSC)* → React context does not exist in RSC, so import271 from **`@/lib/icons/ssr`**, where the weight is pre-bound. Importing272 `@phosphor-icons/react/dist/ssr` directly is ESLint-blocked (it silently273 defaults to `regular`); importing the main entry in a server component274 crashes the build (`createContext` at module scope). Adding an icon to a275 server component? Add its two lines to `src/lib/icons/ssr.tsx`.276- **Exception — solid intent:** status tiles, success checks, destructive277 trash, and logo glyphs pass an explicit `weight="fill"` so they stay solid278 regardless of the global weight. This is the only weight prop in the codebase.279- Semantic layer: `src/components/ui/kortix-icons.ts` (`IconAdd`, `IconDelete`,280 …) re-exports Phosphor icons; prefer it where already adopted. It re-exports281 the client entry, so it is client-graph only.282- Sizing stays Tailwind-first (`size-4`, `size-3.5 shrink-0` in dense buttons);283 the provider's `size: 24` default only covers class-less usages.284- Background on the RSC rule above: the main entry calls `createContext` at285 module scope with no `'use client'` directive, so a server-only module graph286 reaching it crashes the build with287 `TypeError: (0, d.createContext) is not a function`. Phosphor's own SSR entry288 fixes the crash but hardcodes `weight="regular"`, which would quietly ignore289 `DEFAULT_ICON_WEIGHT` — that is why `@/lib/icons/ssr` exists and why the raw290 entry is ESLint-blocked outside it.291292## Motion in components293294**Budget and easings live in [`kortix-brand-guidelines` → Motion](../kortix-brand-guidelines/SKILL.md).**295Read the frequency ladder there before adding any animation. The short version:296297> Kortix is a speed product. **Fast, or none.** Count how many times a day a user298> sees the interaction; that number sets the budget. Constant-use surfaces —299> keyboard nav, dense-list hover, focus moves, table selection — get300> **no animation at all**.301302What that means for the components in this file:303304| Component | Motion |305| --- | --- |306| Row hover in a list, sidebar nav | `transition-colors duration-fast`. Color only — never position. |307| Keyboard-driven selection / arrow-key nav | **None.** `transition-none`. |308| `Disclosure`, `Tabs` | 200ms, `ease-out`. Built in — do not add more. |309| `Modal`, `Dialog`, sheets | 300ms max, `ease-out`, enter from `scale-0.97`. Built into `modal.tsx`. |310| Toast | Owned by `toast.tsx`. Do not restyle its motion. |311| `Loading` | Ships its own rotation. Never add `animate-spin`. |312| Buttons | `active:scale-[0.96]` + `transition-colors`. Nothing else. |313| Skeleton → content | Cross-fade opacity, or nothing. Never a stagger. |314315**Do not add enter animations to list items.** A staggered or animated list is316measurably slower to read and to click than the same list appearing at once, and317the cost is paid on every render.318319### Sanctioned exception — the icon-swap morph320321**Frequency gate:** this pattern is for buttons whose state change is *meaningful322and occasional* — copy → copied, connect → connected, follow → following. It is323**not** for a control the user hits repeatedly in a working session. If in doubt,324hard-swap the icon; a hard swap is never wrong, it is only plain.325326When it does apply: never hard-swap `{done ? <CheckIcon/> : <CopyIcon/>}` with no327transition. Cross-fade the two icons in the same box with **blur + scale +328opacity** so it reads as one morph, not two objects blinking. The blur is what329bridges the two states — without it the eye sees two distinct objects.330331Values: **scale `0.25 → 1`, opacity `0 → 1`, blur `4px → 0`**, spring332**`{ type: 'spring', duration: 0.3, bounce: 0 }`**. `bounce: 0` is the brand.333Always `initial={false}` so nothing animates on first paint.334335```tsx336import { AnimatePresence, motion } from 'motion/react';337338<button339 onClick={handleCopy}340 aria-label={copied ? 'Copied' : 'Copy'}341 className={cn(342 'inline-flex size-7 items-center justify-center rounded-md',343 'text-muted-foreground hover:text-foreground hover:bg-muted-foreground/10',344 'cursor-pointer transition-colors active:scale-[0.96]',345 )}346>347 <span className="relative inline-flex size-3.5 items-center justify-center">348 <AnimatePresence initial={false} mode="popLayout">349 <motion.span350 key={copied ? 'check' : 'copy'}351 initial={{ scale: 0.25, opacity: 0, filter: 'blur(4px)' }}352 animate={{ scale: 1, opacity: 1, filter: 'blur(0px)' }}353 exit={{ scale: 0.25, opacity: 0, filter: 'blur(4px)' }}354 transition={{ type: 'spring', duration: 0.3, bounce: 0 }}355 className="absolute inset-0 inline-flex items-center justify-center"356 >357 {copied ? <CheckIcon className="size-3.5 text-kortix-green" /> : <CopyIcon className="size-3.5" />}358 </motion.span>359 </AnimatePresence>360 </span>361</button>362```363364Rules: both icons share one fixed-size box (`relative size-3.5` parent, each365child `absolute inset-0`) so they overlap and the blur bridges the crossfade.366Confirmed status colour stays a `kortix-*` token (`text-kortix-green`), never raw367palette. Ship a `prefers-reduced-motion` branch — drop the scale and blur, keep368the opacity. **Reference:** `CopyButton` in369`apps/web/src/components/markdown/copy-button.tsx`.370## Spacing cheat sheet (from reference views)371372| Layer | Classes |373| --- | --- |374| Section wrapper → body | `space-y-5` |375| Search + content block | `space-y-4` |376| List of rows / disclosures | `space-y-2` inside `space-y-4` parent |377| Settings major sections | `space-y-8` |378| Tab panel content | `space-y-6` |379| Panel inner padding | `px-4 py-5` (standard), `px-4 py-3` (compact row) |380| Row internal gap | `gap-3` (row), `gap-1.5` (title/meta), `gap-2` (button groups) |381| Detail content below title | `mt-8` |382| No-match empty search | `px-3 py-6 text-center text-xs` |383| `Card` slot rhythm | `px-4` all slots; `pt-5` header / `pb-5` last slot; `gap-5` between slots |384385**Padding never sits on a bordered element that hosts flush children** (tables, lists, seams) — put it on the slots/inner sections. A single padded block may use the one-div shorthand `bg-popover rounded-md border px-4 py-5`.386387## Tokens — see `kortix-brand-guidelines`388389**Every value lives in [`kortix-brand-guidelines`](../kortix-brand-guidelines/SKILL.md).**390Colors, spacing steps, type rungs, radius ladder, elevation steps, easings and391durations are enumerated there as closed allowlists, with the reasons and the392`audit.sh` that enforces them. This file used to restate them; two copies drift,393so the copy is gone.394395The five facts you will get wrong from stock Tailwind habit, restated here only396because they bite immediately:397398| Fact | Consequence |399| --- | --- |400| `--spacing: 0.23rem`, not `0.25rem` | Every spacing utility is 8% tighter. A 16px mockup padding is `p-4`, never `p-[16px]`. |401| `--text-xs: 0.8125rem` = **13px**, not 12px | `text-xs` is the workhorse (1999 uses). There is no rung below it. |402| `--radius: 0.625rem` → `rounded-md` is **8px** | `rounded-md` is the default for every panel, row, table and card. |403| Color is semantic tokens + the seven `kortix-*` accents | No raw palette, no hex, and **no `dark:` for color** — tokens flip themselves. |404| In-flow surfaces are **flat** — border, no shadow | Shadows mean "floats above the page". |405## Status pattern — tinted icon tile406407```tsx408<span className={cn(409 'flex size-9 items-center justify-center rounded-sm',410 merged && 'bg-kortix-green/15',411 failed && 'bg-kortix-red/15',412 open && 'bg-kortix-blue/15',413)}>414 <Icon className={cn('size-5', merged && 'text-kortix-green', …)} />415</span>416```417418Use `weight="fill"` icons at `size-5` inside `size-8`/`size-9` tiles. Pair with `Badge` for text labels when needed (`changes-view.tsx`, `sandbox-provider-coverage.tsx`).419420## Modal pattern (canonical — use `modal.tsx`)421422From `secrets-view.tsx` / `channels-view.tsx` — **not** raw Dialog:423424```tsx425<Modal open={open} onOpenChange={setOpen}>426 <ModalContent className="lg:max-w-lg">427 <ModalHeader>428 <ModalTitle>Title</ModalTitle>429 <ModalDescription>Description</ModalDescription>430 </ModalHeader>431 <form onSubmit={handleSubmit}>432 <ModalBody className="max-h-[60vh] overflow-y-auto">433 {/* fields */}434 </ModalBody>435 <ModalFooter className="sm:justify-between">436 <Button type="button" variant="outline-ghost" onClick={() => setOpen(false)}>437 Cancel438 </Button>439 <Button type="submit" disabled={pending}>440 {pending ? <Loading className="size-4 shrink-0" /> : null}441 Save442 </Button>443 </ModalFooter>444 </form>445 </ModalContent>446</Modal>447```448449Destructive confirms → `ConfirmDialog`, not a red-styled `Modal` trigger.450451## Tabs pattern452453- **Primary section tabs:** `TabsList type="underline"` + `TabsTrigger className="w-fit flex-none"` (`changes-view.tsx`, `channels-view.tsx`)454- **Filter / status tabs:** `TabsListCompact` + `TabsTriggerCompact` (`changes-view.tsx`, `secrets-view.tsx`)455- Tab badge count: `<Badge variant="secondary" size="sm">` inside trigger456457## Loading pattern (canonical)458459**Every in-flight spinner is `Loading` from `loading.tsx`. There are no exceptions and no second spinner in this codebase.** The component ships its own rotate/dash animation, so it never needs `animate-spin`.460461**No icon is ever a spinner.** Not `CircleNotchIcon`, not `SpinnerIcon`, not `SpinnerGapIcon`, not any other Phosphor glyph, whatever it is aliased to (`Loader`, `Loader2`, `IconLoader`, …). If you catch yourself writing `animate-spin` on an icon, the answer is `Loading`. `kortix-icons.ts` deliberately exports **no** loader icon so the semantic layer can't offer you one.462463```tsx464import Loading from '@/components/ui/loading';465466// Button pending (replaces action icon)467<Button disabled={pending}>468 {pending ? <Loading className="size-3.5 shrink-0" /> : <PlusIcon className="size-3.5 shrink-0" />}469 Save470</Button>471472// Section header action473<Button size="sm" variant="secondary" disabled={pending}>474 {pending ? <Loading className="size-4 shrink-0" /> : <PlusIcon className="size-4" />}475 New476</Button>477478// Inline / modal submit479{pending ? <Loading className="size-4 shrink-0" /> : null}480```481482For page-level loading placeholders use **`Skeleton`** (shape-matched). Use **`Loading`** only for active async operations (submit, fetch-in-button, mutation pending).483484## Search + loading + empty flow485486Standard content block (`agents-page.tsx` pattern):487488```tsx489<div className="space-y-4">490 <InputGroupSearch>…<InputGroupSearchInput variant="popover" />…</InputGroupSearch>491 {isLoading ? (492 <div className="space-y-1">{/* Skeleton h-7 rounded-md × 5 */}</div>493 ) : isError ? (494 <ErrorState size="sm" action={<Button variant="outline" size="sm">Retry</Button>} />495 ) : items.length === 0 ? (496 <EmptyState icon={…} size="sm" action={…} />497 ) : (498 /* list */499 )}500</div>501```502503## Dos & Don'ts504505- ✅ Section shell → `CustomizeSectionWrapper`. ❌ hand-rolled outer flex + header.506- ✅ Panels → `Card` (`card.tsx`) or `bg-popover rounded-md border` with `px-4 py-5`. ❌ `SectionCard`, ❌ padding on a bordered element that hosts flush children.507- ✅ Elevation → ladder step (`shadow-md` popovers, `shadow-lg` modals); in-flow panels stay flat with a border. ❌ `dark:shadow-*`, ❌ `shadow-[…]` when a step fits.508- ✅ Lists → `<ul className="space-y-2">` + entity row classes. ❌ `List` / `ListRow`, ❌ `divide-y` Card lists.509- ✅ Expandable config → `Disclosure` + `Button variant="popover"`. ❌ custom accordion, ❌ nested `rounded-md` inside rounded parent.510- ✅ Modals → `Modal` from `modal.tsx`. ❌ `Dialog`/`DialogContent` in features.511- ✅ Destructive actions → `ConfirmDialog` (or the inline two-step Cancel/confirm swap, `channels-view.tsx`). ❌ firing a delete/revoke mutation directly from a `variant="destructive"` click.512- ✅ Tooltips → `Hint`. ❌ `Tooltip` primitives in features.513- ✅ Toasts → `@/components/ui/toast` helpers. ❌ `@/lib/toast`, raw sonner.514- ✅ Badges → `<Badge size="sm" variant="…">`. ❌ hand-rolled chip spans.515- ✅ Status → tinted icon tile + optional `Badge`. ❌ raw palette icon colors.516- ✅ Color → `kortix-*` + semantic tokens. ❌ `text-emerald-600`, `bg-amber-500`.517- ✅ Meta separators → `InlineMeta` or `text-muted-foreground/40` bullet (`•`). ❌ inconsistent separators.518- ✅ Empty → `EmptyState`. ❌ centered `<p>` only.519- ✅ Alerts → `InfoBanner`. ❌ hand-rolled colored banners.520- ✅ Pending spinners → `Loading` from `loading.tsx`. ❌ `CircleNotchIcon`, `SpinnerIcon`, or any `animate-spin` icon.521- ✅ Motion → count the frequency first; `transition-colors duration-fast` on hover, `ease-out` on enter/exit, `active:scale-[0.96]` on press. ❌ `transition-all`, ❌ bare `transition`, ❌ `ease-in`, ❌ anything over 300ms in product UI.522- ✅ Keyboard-driven interactions → `transition-none`. ❌ animating arrow-key nav or focus moves.523- ✅ Every animation ships a `prefers-reduced-motion` variant. ❌ one variant only.524- ✅ List items appear at once. ❌ stagger in product UI.525526## Workflow checklist5275281. **Load [`kortix-brand-guidelines`](../kortix-brand-guidelines/SKILL.md)** — the value allowlists and the motion budget. Then this file, then [`make-interfaces-feel-better`](../../../apps/web/.agents/skills/make-interfaces-feel-better/SKILL.md).5292. **Read the closest reference view** from the table above. Copy structure, spacing, and primitives — don't invent a new layout dialect.5303. Skim `/design-system` and `src/components/ui/` for anything not covered by the reference.5314. Compose: `CustomizeSectionWrapper` → search/panel/row/disclosure/table → `Badge` + `Hint` + `Modal` + `toast` + `Loading` + `EmptyState`. **Never** `SectionCard`, `List`, or `CircleNotchIcon`.5325. Status → tinted icon tile. Color → `kortix-*`. Radius → `rounded-md` (panel), `rounded-none` (flush trigger). Elevation → overlays only; flat border for in-flow panels.5336. **Motion last.** Count how often a user sees it. Constant → none. Otherwise ≤300ms, `ease-out`, named property, token duration, reduced-motion variant.5347. New primitive? Tokens only, tiny API, add to `/design-system`.5358. Verify: `.claude/skills/kortix-brand-guidelines/audit.sh <your paths>` clean, no banned imports, no nested rounding, light + dark, `tsc` clean, polish checklist from `make-interfaces-feel-better`.5369. Replay the interaction ten times. If you still notice the animation, it is too much.