Kortix Brand Guidelines
The value law. Every visual number in a Kortix surface comes from a fixed
allowlist. If a value is not in a table in this file, it does not exist and you
may not write it.
This file governs what value you are allowed to pick. Two companions govern
other things — load them too, they do not overlap:
| Skill |
Owns |
| kortix-brand-guidelines (this file) |
The allowlists. Which color, spacing step, type rung, radius, elevation, easing and duration is legal — and whether to animate at all. Rejected defaults. The audit. |
| kortix-design-system |
Which component to compose (Card, Modal, Hint, Loading), reference implementations, banned primitives. |
make-interfaces-feel-better (apps/web/.agents/skills/) |
Polish inside the constraints — optical alignment, concentric radius, tabular nums. |
When they disagree, this file wins on values and on motion restraint,
kortix-design-system wins on components, make-interfaces-feel-better wins on
polish. A polish skill will always suggest more motion than Kortix wants; this
file is the ceiling it operates under.
Source of truth: apps/web/src/app/globals.css. Every number below was read
from it. If globals.css changes, this file is wrong and globals.css is right.
Priority order — when requirements compete
Resolve conflicts top-down. A lower rule never overrides a higher one.
- Accessibility and legibility. Contrast, focus visibility, hit area, reduced motion. Never traded away.
- Token fidelity. A value from the allowlist, even if it is 1px off the mockup. The mockup is a suggestion; the token is the contract.
- Reuse. An existing component or reference implementation over a new composition.
- Light/dark parity by construction. Semantic tokens that flip themselves, never a hand-written
dark: pair.
- Perceived speed. Kortix is a speed product. Between two legal options, take the one that feels faster — including "no animation".
- Density. Kortix is dense-but-calm. When two legal values both work, take the tighter one.
- Local aesthetic preference. Last. This is where slop enters.
The five passes
Do these in order. Do not start writing classNames during pass 1.
- Name the surface. Is it in page flow, or floating above it? In-flow surfaces get a border and no shadow. Floating surfaces get a shadow. This one question decides elevation, radius, and background token.
- Pick the component. Read the closest reference implementation listed in
kortix-design-system. Copy its structure and its spacing before inventing any.
- Fill values from the allowlists below. Color → semantic or
kortix-*. Spacing → scale step. Type → named rung. Radius → the surface's row. Never a raw number.
- Decide motion last, and count first. How many times a day does a user see this? That number sets the budget — see the frequency ladder under Motion. The default is no animation.
- Audit. Run
./audit.sh (next to this file) over the files you changed. Fix every hit before you claim done.
Color
The law
- Semantic tokens and
kortix-* accents only. Never a raw Tailwind palette class (bg-emerald-500, text-red-400), never a hex, rgb, hsl, or oklch literal in a component.
- Never write
dark: for color. Every semantic token already carries both themes. A dark:bg-… in a diff means you used the wrong token.
- Hierarchy comes from surface lift, never from opacity on text.
text-foreground/60 is wrong; text-muted-foreground is right.
- Blue is a signal, never a fill.
--ring / kortix-base paints focus, links, and selection. It is not a background.
Surface ladder
Four neutral steps. Pick by depth, not by taste.
| Role |
Token |
Light |
Dark |
Use |
| canvas |
bg-background |
#ffffff |
#090909 |
The page itself |
| surface |
bg-surface |
#fcfcfc |
#141414 |
Full-bleed shell pane — a full-screen modal or settings shell that replaces the page |
| surface-1 |
bg-card / bg-accent / bg-sidebar |
#f4f4f4 |
#141414 |
Lifted region, sidebar, hover surface |
| surface-2 |
bg-secondary / bg-muted / bg-input |
#ececec |
#1c1c1c |
Inset controls, input wells, chips |
| top surface |
bg-popover |
#ffffff |
#141414 |
Panels, rows, overlays — the default panel fill |
| ink |
text-foreground |
#000000 |
#ffffff |
Primary text |
| ink-muted |
text-muted-foreground |
#666666 |
#999999 |
Descriptions, meta, idle state |
| hairline |
border-border |
#e2e2e2 |
#262626 |
Every border. There is one border color. |
bg-popover is the panel default — not bg-card. bg-card is the washed-gray
region tone; bg-popover is the flat elevated surface panels sit on.
bg-surface is the shell tone, not a panel tone. It paints one thing: a
full-bleed surface that stands in for the page (the settings modal at
side="fullscreen"). In light it reads as a hair off white, so the shell is
distinguishable from a bg-background page behind it without the washed cast of
bg-card. In dark it is the same #141414 as surface-1. Do not reach for it on
a card, a row, or a panel.
Brand accents — the complete set
These seven are the only sources of hue in the product UI.
| Token |
Meaning — use it for nothing else |
kortix-base |
Brand / focus / links / selection (aliases --ring, #0099ff) |
kortix-green |
success, running, connected, merged |
kortix-red |
error, failed |
kortix-orange |
warning, needs attention |
kortix-yellow |
pending, informational |
kortix-blue |
open, in-review, neutral-informational |
kortix-purple |
reserved — no default meaning; do not assign one ad hoc |
| (none) |
idle / neutral → text-muted-foreground, not a hue |
Tinted fills use /15 (bg-kortix-green/15); the glyph takes the solid token.
Those are the two opacities status color is allowed to take.
Interaction states
| State |
Value |
Never |
| Hover (transient) |
hover:bg-hover or hover:bg-accent |
a darker gray guess |
| Selected (persistent) |
bg-active, or bg-primary/[0.05]–bg-primary/[0.08] |
bg-muted |
| Focus |
ring-ring (focus-visible:) |
removing the ring |
| Disabled |
disabled:opacity-50 + disabled:pointer-events-none |
a muted color swap |
--hover and --active are translucent ink, so they compose over any
substrate. That is why they beat a solid gray.
Escape hatch
There is exactly one legal reason to reach outside this section: a third-party
brand color (a provider logo, an OAuth button). Put it in a named constant next
to the component, never inline in a className.
Spacing — read this before you type a number
--spacing: 0.23rem (globals.css:755). Not Tailwind's 0.25rem.
Every spacing utility in this app is 8% tighter than stock Tailwind:
| Utility |
Kortix computes to |
Stock Tailwind |
gap-1 |
0.23rem ≈ 3.7px |
4px |
gap-2 |
0.46rem ≈ 7.4px |
8px |
p-3 |
0.69rem ≈ 11.0px |
12px |
p-4 |
0.92rem ≈ 14.7px |
16px |
p-6 |
1.38rem ≈ 22.1px |
24px |
The consequence: a mockup that says "16px padding" becomes p-4, not
p-[16px]. Writing p-[16px] pins one element to a grid the rest of the app is
not on. This is the single most common way Kortix UI drifts.
Allowed steps
0 0.5 1 1.5 2 2.5 3 3.5 4 5 6 8 10 12 16 20 24
Below 4, half-steps are legal and common (gap-1.5 is the second most used
spacing class in the app). Above 4, use whole steps only.
Never an arbitrary spacing value. p-[13px], gap-[10px], mt-[7px] are
all rejected. There is no exception for "matching a design".
Canonical rhythm
| Layer |
Value |
| Page container |
mx-auto w-full max-w-2xl |
| Section vertical padding |
px-4 py-10 pb-20 lg:py-20 |
| Header → body |
space-y-5 |
| Settings major sections |
space-y-8 |
| Tab panel content |
space-y-6 |
| Search + content block |
space-y-4 |
| List of rows |
space-y-2 |
| Panel inner padding |
px-4 py-5 (standard) · px-4 py-3 (compact) · px-4 py-2 (row) |
| Row internal gap |
gap-3 |
| Title ↔ meta gap |
gap-1.5 |
| Button group gap |
gap-2 |
Give every gap one owner. A gap is produced by the parent's gap/space-y
or by a child's margin — never both. If you are adding mt-* to a child inside
a space-y-* parent, you have two owners and the rhythm is already broken.
Padding never sits on a bordered element that hosts flush children. A panel
with a table or list running edge-to-edge puts padding on its inner sections, not
on the bordered box.
Typography
Roobert (--font-sans) for everything. Roobert Mono (--font-mono) only for
code, commands, paths, IDs, and keys. Mono is not a decorative choice.
The scale — these rungs and no others
Kortix redefines the small end. text-xs is 13px here, not 12px.
| Class |
Size |
Line height |
Role |
text-xs |
0.8125rem / 13px |
1.23 |
Meta, captions, row descriptions — the workhorse |
text-sm |
0.875rem / 14px |
1.43 |
Body, row titles, labels, button text |
text-base |
1rem / 16px |
1.5 |
Long-form prose only |
text-lg |
1.125rem / 18px |
1.56 |
Rare — sub-headings |
text-xl |
1.25rem / 20px |
1.4 |
Section page title |
text-2xl |
1.5rem / 24px |
1.33 |
Detail-view title |
text-3xl+ |
30px+ |
— |
Marketing only. Not in app chrome. |
text-md (0.9rem) exists in @theme but is used 8 times in the whole app.
Treat it as non-canonical — do not add uses.
Weight and tracking
| Need |
Value |
| Body, meta |
font-normal (default — do not write it) |
| Row title, label, active nav |
font-medium |
| Detail title |
font-semibold tracking-tight |
| Anything else |
Not available. There is no font-bold in app chrome. |
tracking-tight pairs with text-2xl and up. Never track body text.
Roles
| Role |
Exact classes |
| Section page title |
text-xl font-medium text-foreground |
| Detail title |
text-2xl font-semibold tracking-tight |
| Panel section label |
the Label component (text-sm font-medium) |
| Row title |
text-sm font-medium |
| Row meta / description |
text-xs text-muted-foreground |
Never text-[11px] or any arbitrary size. If 13px is too big, the element is
wrong, not the scale.
Radius
--radius: 0.625rem (10px) is the root. The ladder derives from it.
| Class |
Computes to |
Surface |
rounded-sm |
6px |
Status icon tiles, small chips |
rounded-md |
8px |
Panels, rows, tables, cards, buttons — the default |
rounded-lg |
10px |
Inputs and selects (via variant="popover") |
rounded-xl |
14px |
Rare. Large media containers only. |
rounded-2xl |
16px |
Marketing surfaces only. Not in app chrome. |
rounded-full |
— |
Pills: badges, avatars, pill buttons |
rounded-none |
0 |
Flush seam inside a disclosure |
Rules:
- Default to
rounded-md. If you are unsure, it is rounded-md.
- No nested rounding. A rounded child inside a rounded parent is banned — either the child is flush (
rounded-none) or the parent is not rounded.
- No
rounded-[…]. 58 exist in the codebase; add no more.
- Bare
rounded (4px, a Tailwind default outside this ladder) is legacy. Prefer rounded-sm.
Elevation
Verified at HEAD: globals.css defines no shadow ladder. shadow-* renders
stock Tailwind shadows. (kortix-design-system describes a custom
four-sided ladder in @theme — that is stale; grep -an "shadow-" globals.css
returns only --shadow-liquid-glass.) The semantics below still hold.
| Step |
Use |
| none — border only |
Panels, rows, tables, cards. Anything in page flow is flat. |
shadow-xs |
Chips, slider thumbs |
shadow-sm |
Sticky bars, segmented controls |
shadow-md |
Dropdowns, selects, popovers |
shadow-lg |
Modals, sheets, toasts |
shadow-xl |
Command palette, floating windows |
- Elevation means floats above the page. If it sits in the flow, it gets a border.
- Overlays pair shadow with a hairline border:
bg-popover border shadow-md.
- Never
dark:shadow-*.
- Never
shadow-[…] when a step fits.
Motion
Kortix is a speed product. The motion budget is: fast, or none.
Animation here exists to make the UI feel faster and to explain where a thing
came from. It does not exist to be noticed. If a reviewer's first comment is
about the animation, the animation is wrong — they should have noticed the
interface, and felt that it was quick.
Two rules carry most of the weight:
The more you animate, the less each animation is worth. If everything
moves, nothing stands out.
Sometimes the best animation is no animation. "Should this animate at all?"
is a real question with "no" as a frequent, correct answer.
Pass 0 — the frequency ladder
Before choosing a duration, count how often a user sees this in a working
day. Frequency sets the budget. Nothing else does — not how impressive it
looks in isolation, not how much effort the animation took.
| Seen |
Budget |
Examples |
| Constantly — tens of times an hour |
None. transition-none |
Keyboard list nav, row hover in a dense list, focus moves, selection in a table, anything driven by an arrow key |
| Often — several times a session |
100–150ms, opacity + transform only |
Dropdown, popover, tooltip, tab switch, disclosure, hover on a non-dense target |
| Occasionally — once or twice a session |
200–300ms |
Modal, sheet, drawer, toast, panel swap |
| Rarely — first run, or a moment worth marking |
≤300ms, blur allowed |
Empty → first content, a completed deploy, a destructive confirm |
| Marketing only |
up to 500ms, one hero moment per viewport |
Landing intro, feature illustration |
Keyboard-initiated actions are never animated. A user drives them hundreds of
times a day; motion makes the interface feel laggy and disconnected from the key
they just pressed. This has no exceptions.
Easing blueprint
| Curve |
Use it for |
Notes |
ease-out |
Enter and exit. Anything appearing or leaving. |
The default. Fast at the start = feels responsive. Reach here first. |
ease-in-out |
Elements already on screen that move, resize, or morph |
Accelerate-then-decelerate, like a car |
ease |
Hover transitions of color, background, opacity |
Gentler; correct for small state changes |
linear |
Marquee, progress, hold-to-delete, continuous rotation |
Only where constant rate is the meaning |
ease-in |
Nothing. |
Starts slow → the UI feels sluggish. Two uses exist in apps/web; add none. |
ease-in and linear are the two ways a Kortix surface starts feeling slow.
Same duration, worse feel — easing changes perceived speed independently of time.
Duration
Product motion stays under 300ms. Over 300ms is a marketing budget, or a bug.
| Token |
Value |
Use |
duration-fast |
100ms |
Hover color/opacity |
duration-normal |
150ms |
The default UI transition |
duration-moderate |
200ms |
Disclosure, accordion, tab |
duration-slow |
300ms |
Modal, drawer, sheet — the ceiling |
duration-slower |
500ms |
Marketing only |
Two legitimate reasons to go longer, both requiring a comment saying which:
a large element (bigger reads as heavier, so it moves slower), or a steep
custom curve whose front-loading makes a longer duration still feel snappy.
"It looks nicer" is not one of them.
Known drift: raw values (duration-150 ×104, duration-200 ×67,
duration-500 ×23) outnumber tokens (duration-fast ×10, duration-normal
×5) by roughly 12:1. The tokens are correct and the raw values are debt.
Write tokens.
Properties
Animate opacity, transform, filter, color, background-color and
nothing else. These are the compositor-friendly properties; the rest cause
layout and drop frames.
- Never
transition-all, and never bare transition. Both animate every
property that changes, including ones you did not intend, on every state
change. Name the property: transition-colors, transition-transform,
transition-opacity. (Outside components/ui/, apps/web has 26
transition-all and 17 bare transition — do not add more.)
transition-[max-width,opacity] and other explicit arbitrary property
lists are fine — naming two properties is still naming them. The ban is on
unnamed transitions, not on the bracket syntax.
- Never animate
height, width, top, left, or margin. For height,
use a grid-rows or motion/react height animation, not a CSS height
transition.
Fixed values
| Thing |
Value |
Why |
| Press feedback |
active:scale-[0.96] |
The house press (169 uses). Makes the UI feel like it is listening. |
| Enter scale floor |
0.9–0.97 |
Never scale(0). Things do not come from nothing; a deflated balloon still has a shape. |
| Spring |
{ type: 'spring', duration: 0.3, bounce: 0 } |
bounce: 0 is the brand. Buttery, never playful. |
| Bounce > 0 |
Drag-release gestures only |
A drag applied force, so a settle reads as physical. A click did not. |
| Blur bridge |
filter: blur(4px) → blur(0) |
Only to bridge a state swap where two objects would otherwise blink. Not decoration. |
| Popover origin |
transform-origin: var(--radix-…-transform-origin) |
Scale from the trigger. The center default is wrong and reads as a floating box. |
Hover, tooltips, and touch
- Hover effects must not move layout. A hover that shifts position moves the
element out from under the cursor and flickers between states. Change color,
background, opacity, or
transform — not position in flow.
- Hover is
hover: (Tailwind v4 already gates it to hover-capable devices). Do
not hand-roll a hover effect that fires on touch.
- Tooltips: delay before the first, then no delay and no animation for
subsequent ones while the group is active. The delay prevents accidental
activation; repeating it makes the UI feel slow.
Reduced motion
Every animation ships two variants. prefers-reduced-motion: reduce means
remove the movement, not remove the feedback — keep opacity and color, drop
transform and anything that translates or scales.
// Tailwind
className="motion-safe:transition-transform motion-reduce:transition-opacity"
// motion/react
const reduce = useReducedMotion();
<motion.div animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0 }} />
Orchestration
One moving thing at a time. Stagger is a marketing device. In product UI a
staggered list is strictly slower to read and to click than the same list
appearing at once — the delay is charged to the user, every time.
Rejected defaults
These are the shapes LLM-generated UI reaches for by reflex. All are wrong here.
Recognizing one in your own output means stop and re-pick.
- A value in brackets —
p-[16px], text-[13px], gap-[10px], rounded-[12px]. The scale exists; use it.
- A raw Tailwind palette color —
bg-emerald-500, text-red-400, border-zinc-200.
- A hex, rgb, hsl, or oklch literal in a component file.
- A
dark: variant for color, background, border, or shadow.
- A gradient used as decoration. Kortix is flat.
- A shadow on an in-flow card. Border only.
rounded-2xl / rounded-3xl on app chrome. That is a marketing radius.
- Nested rounded containers — a rounded box inside a rounded box.
- Opacity on text for hierarchy —
text-foreground/60. Use text-muted-foreground.
font-bold in app UI. The ladder stops at font-semibold.
- All-caps eyebrow labels and letter-spaced micro-headings.
- A generic 3-up card grid used because content had to go somewhere.
- A colored alert panel hand-rolled from
bg-red-50 border-red-200. Use InfoBanner.
- An icon spinning as a loader.
Loading is the only spinner. Never animate-spin on an icon.
- A second border color. There is one
--border.
- Emoji as UI iconography. Phosphor only.
- A new font. Roobert and Roobert Mono are the entire type system.
Motion:
- Animating because you can. The default answer to "should this animate?" is no.
transition-all or bare transition. Name the property.
ease-in or linear on a UI transition. Both read as slow.
- A duration over 300ms in product UI.
- Animating a keyboard-driven interaction — arrow-key list nav, focus moves.
- A hover effect that moves layout. It flickers.
- A staggered list in product UI. The delay is billed to the user.
- Entering from
scale(0) or opacity with a big y translate.
- A popover scaling from
center instead of from its trigger.
- A spring with
bounce > 0 on anything that was not dragged.
- Animating
height, width, top, or left.
- Shipping one motion variant.
prefers-reduced-motion is not optional.
Rationalization table
Every one of these has produced a real regression. If you think one of them, stop.
| Thought |
Reality |
"The design says 16px, so p-[16px]" |
--spacing is 0.23rem. p-4 is the translation. Bracket values desync this element from every other one. |
| "There's no token for this exact shade" |
Then the shade is wrong. Pick the nearest role token. |
"emerald-500 is basically kortix-green" |
It is not, and it does not flip in dark mode. Two greens on one screen is the bug. |
"I'll add dark: just for this one" |
Every dark: for color is a token you failed to use. It will drift the moment the theme changes. |
| "A subtle shadow makes the card pop" |
In-flow surfaces are flat. "Pop" is not a requirement. |
"rounded-2xl looks more modern" |
It looks like a different product. rounded-md. |
| "It's just a one-off marketing-ish section" |
App chrome and marketing have different radius/type rules. Name which one you are in, then follow that column. |
| "Tailwind's default spacing is fine here" |
This app overrode it. Stock intuition is wrong by 8% everywhere. |
| "13px is too big for this caption" |
The scale has no rung below text-xs. Change the layout, not the scale. |
| "I'll match the tokens later" |
There is no later. The audit runs on this diff. |
| "The existing file already does it this way" |
Legacy is not permission. Do not add the 26th text-emerald-500. |
"It's inside a cn() so it's fine" |
The audit greps source text. Location does not launder a raw value. |
| "A little animation here would delight the user" |
The user has a goal, not a wish to be delighted. Delight that repeats becomes friction. |
| "It's only 400ms, that's still quick" |
Product ceiling is 300ms. 400ms is a marketing budget spent in a product surface. |
| "The stagger makes the list feel considered" |
It makes the list slower to read and slower to click, every single time. |
"transition is shorter to type than transition-colors" |
It animates properties you did not choose, including layout ones. Name the property. |
| "This dropdown animation looked great in the demo" |
A demo is one viewing. Estimate the fiftieth. That is the real design target. |
"I'll add prefers-reduced-motion later" |
It is the second half of the animation, not a follow-up. Ship both variants together. |
"ease-in looks smoother on the way out" |
It starts slow, so the whole UI reads as sluggish. ease-out for enter and exit. |
Red flags — stop and re-pick
- You typed
[ inside a className for a size, color, spacing, or radius.
- You typed a color word that is not
kortix-* or a semantic token.
- You typed
dark:.
- You typed a number that came from a screenshot or a Figma inspector.
- You are adding
mt-* to a child of a space-y-* parent.
- You wrote
shadow-* on something that sits in page flow.
- You reached for
rounded-xl or larger inside apps/web app chrome.
- You are about to say "close enough".
- You added an animation without first counting how often a user sees it.
- You typed
transition or transition-all with no property name.
- You typed a
duration- over 300, or a raw ms instead of a token.
- You are animating something a keyboard drives.
- You wrote an enter animation and no
motion-reduce / useReducedMotion branch.
- You are proud of how the animation looks. Check that it still feels fast on the fiftieth run.
All of these mean: go back to the allowlist.
Audit
Run before claiming done. It is mechanical — do not eyeball it.
.claude/skills/kortix-brand-guidelines/audit.sh apps/web/src/features/your-feature
Zero output means clean. Every hit is either a fix or a written justification in
the PR body. audit.sh with no argument audits apps/web/src, which will report
the existing legacy debt — scope it to your own paths.
Marketing vs app chrome
Two columns, one brand. Name which one you are in before pass 3.
|
App chrome (apps/web product surfaces) |
Marketing (landing, changelog, docs shells) |
| Max type |
text-2xl |
text-7xl |
| Radius |
rounded-md |
rounded-2xl allowed |
| Shadow |
Overlays only |
Large previews may use shadow-2xl |
| Density |
Dense — text-xs workhorse |
Open — text-base prose |
| Color |
Semantic + kortix-* |
Same. No exception. |
| Spacing scale |
Same steps |
Same steps |
| Motion ceiling |
300ms, one moving thing |
500ms, one hero moment per viewport |
| Stagger |
Never |
Allowed, once, on intro |
| Intro animation |
Never |
Once per visit — must not replay on back-navigation |
Color, spacing, and the ban on ease-in never fork. Only type ceiling, radius
ceiling, density, and motion budget do.
Checklist
- Named the surface: in-flow (border, flat) or floating (shadow)?
- Read the closest reference implementation from
kortix-design-system.
- Every color is a semantic token or
kortix-*. Zero dark: for color.
- Every spacing value is a scale step. Zero brackets.
- Every font size is a named rung. Zero brackets.
- Radius is
rounded-md unless the table says otherwise. No nesting.
- Shadow only if it floats.
- Motion: counted the frequency first. Constant → none. Product → ≤300ms,
ease-out, token duration, named property, active:scale-[0.96], reduced-motion variant shipped.
audit.sh is clean on your paths.
- Checked light and dark by toggling the theme, not by reading the code.
- Replayed the interaction ten times. It still feels fast, and you stopped noticing the animation.
1---2name: kortix-brand-guidelines3description: Use when choosing any concrete visual or motion value in a Kortix surface — a padding, gap, margin, font size, color, radius, shadow, easing, duration, transition, hover or press state — or when reviewing a diff that introduces one. Load before writing the first className. Symptoms that it applies - reaching for p-[16px], text-[13px], bg-emerald-500, rounded-2xl, shadow-lg, dark:, transition-all, ease-in, duration-500, a stagger, a hex code, or any number that came from a mockup, from stock Tailwind habit, or from taste rather than from a token.4---56# Kortix Brand Guidelines78**The value law.** Every visual number in a Kortix surface comes from a fixed9allowlist. If a value is not in a table in this file, it does not exist and you10may not write it.1112This file governs **what value you are allowed to pick**. Two companions govern13other things — load them too, they do not overlap:1415| Skill | Owns |16| --- | --- |17| **kortix-brand-guidelines** (this file) | The allowlists. Which color, spacing step, type rung, radius, elevation, easing and duration is legal — and **whether to animate at all**. Rejected defaults. The audit. |18| **kortix-design-system** | Which *component* to compose (`Card`, `Modal`, `Hint`, `Loading`), reference implementations, banned primitives. |19| **make-interfaces-feel-better** (`apps/web/.agents/skills/`) | Polish inside the constraints — optical alignment, concentric radius, tabular nums. |2021When they disagree, **this file wins on values and on motion restraint**,22`kortix-design-system` wins on components, `make-interfaces-feel-better` wins on23polish. A polish skill will always suggest more motion than Kortix wants; this24file is the ceiling it operates under.2526**Source of truth:** `apps/web/src/app/globals.css`. Every number below was read27from it. If globals.css changes, this file is wrong and globals.css is right.2829---3031## Priority order — when requirements compete3233Resolve conflicts top-down. A lower rule never overrides a higher one.34351. **Accessibility and legibility.** Contrast, focus visibility, hit area, reduced motion. Never traded away.362. **Token fidelity.** A value from the allowlist, even if it is 1px off the mockup. The mockup is a suggestion; the token is the contract.373. **Reuse.** An existing component or reference implementation over a new composition.384. **Light/dark parity by construction.** Semantic tokens that flip themselves, never a hand-written `dark:` pair.395. **Perceived speed.** Kortix is a speed product. Between two legal options, take the one that feels faster — including "no animation".406. **Density.** Kortix is dense-but-calm. When two legal values both work, take the tighter one.417. **Local aesthetic preference.** Last. This is where slop enters.4243---4445## The five passes4647Do these in order. Do not start writing classNames during pass 1.48491. **Name the surface.** Is it in page flow, or floating above it? In-flow surfaces get a **border and no shadow**. Floating surfaces get a shadow. This one question decides elevation, radius, and background token.502. **Pick the component.** Read the closest reference implementation listed in `kortix-design-system`. Copy its structure and its spacing before inventing any.513. **Fill values from the allowlists below.** Color → semantic or `kortix-*`. Spacing → scale step. Type → named rung. Radius → the surface's row. Never a raw number.524. **Decide motion last, and count first.** How many times a day does a user see this? That number sets the budget — see the frequency ladder under *Motion*. The default is no animation.535. **Audit.** Run `./audit.sh` (next to this file) over the files you changed. Fix every hit before you claim done.5455---5657## Color5859### The law6061- **Semantic tokens and `kortix-*` accents only.** Never a raw Tailwind palette class (`bg-emerald-500`, `text-red-400`), never a hex, rgb, hsl, or oklch literal in a component.62- **Never write `dark:` for color.** Every semantic token already carries both themes. A `dark:bg-…` in a diff means you used the wrong token.63- **Hierarchy comes from surface lift, never from opacity on text.** `text-foreground/60` is wrong; `text-muted-foreground` is right.64- **Blue is a signal, never a fill.** `--ring` / `kortix-base` paints focus, links, and selection. It is not a background.6566### Surface ladder6768Four neutral steps. Pick by depth, not by taste.6970| Role | Token | Light | Dark | Use |71| --- | --- | --- | --- | --- |72| canvas | `bg-background` | `#ffffff` | `#090909` | The page itself |73| surface | `bg-surface` | `#fcfcfc` | `#141414` | Full-bleed shell pane — a full-screen modal or settings shell that replaces the page |74| surface-1 | `bg-card` / `bg-accent` / `bg-sidebar` | `#f4f4f4` | `#141414` | Lifted region, sidebar, hover surface |75| surface-2 | `bg-secondary` / `bg-muted` / `bg-input` | `#ececec` | `#1c1c1c` | Inset controls, input wells, chips |76| top surface | `bg-popover` | `#ffffff` | `#141414` | **Panels, rows, overlays** — the default panel fill |77| ink | `text-foreground` | `#000000` | `#ffffff` | Primary text |78| ink-muted | `text-muted-foreground` | `#666666` | `#999999` | Descriptions, meta, idle state |79| hairline | `border-border` | `#e2e2e2` | `#262626` | Every border. There is one border color. |8081`bg-popover` is the panel default — not `bg-card`. `bg-card` is the washed-gray82region tone; `bg-popover` is the flat elevated surface panels sit on.8384`bg-surface` is the shell tone, not a panel tone. It paints one thing: a85full-bleed surface that stands in for the page (the settings modal at86`side="fullscreen"`). In light it reads as a hair off white, so the shell is87distinguishable from a `bg-background` page behind it without the washed cast of88`bg-card`. In dark it is the same `#141414` as surface-1. Do not reach for it on89a card, a row, or a panel.9091### Brand accents — the complete set9293These seven are the **only** sources of hue in the product UI.9495| Token | Meaning — use it for nothing else |96| --- | --- |97| `kortix-base` | Brand / focus / links / selection (aliases `--ring`, `#0099ff`) |98| `kortix-green` | success, running, connected, merged |99| `kortix-red` | error, failed |100| `kortix-orange` | warning, needs attention |101| `kortix-yellow` | pending, informational |102| `kortix-blue` | open, in-review, neutral-informational |103| `kortix-purple` | reserved — no default meaning; do not assign one ad hoc |104| *(none)* | idle / neutral → `text-muted-foreground`, not a hue |105106Tinted fills use `/15` (`bg-kortix-green/15`); the glyph takes the solid token.107Those are the two opacities status color is allowed to take.108109### Interaction states110111| State | Value | Never |112| --- | --- | --- |113| Hover (transient) | `hover:bg-hover` or `hover:bg-accent` | a darker gray guess |114| Selected (persistent) | `bg-active`, or `bg-primary/[0.05]`–`bg-primary/[0.08]` | `bg-muted` |115| Focus | `ring-ring` (`focus-visible:`) | removing the ring |116| Disabled | `disabled:opacity-50` + `disabled:pointer-events-none` | a muted color swap |117118`--hover` and `--active` are **translucent ink**, so they compose over any119substrate. That is why they beat a solid gray.120121### Escape hatch122123There is exactly one legal reason to reach outside this section: a **third-party124brand color** (a provider logo, an OAuth button). Put it in a named constant next125to the component, never inline in a className.126127---128129## Spacing — read this before you type a number130131**`--spacing: 0.23rem`** (globals.css:755). Not Tailwind's `0.25rem`.132133Every spacing utility in this app is **8% tighter than stock Tailwind**:134135| Utility | Kortix computes to | Stock Tailwind |136| --- | --- | --- |137| `gap-1` | 0.23rem ≈ **3.7px** | 4px |138| `gap-2` | 0.46rem ≈ **7.4px** | 8px |139| `p-3` | 0.69rem ≈ **11.0px** | 12px |140| `p-4` | 0.92rem ≈ **14.7px** | 16px |141| `p-6` | 1.38rem ≈ **22.1px** | 24px |142143**The consequence:** a mockup that says "16px padding" becomes **`p-4`**, not144`p-[16px]`. Writing `p-[16px]` pins one element to a grid the rest of the app is145not on. This is the single most common way Kortix UI drifts.146147### Allowed steps148149`0` `0.5` `1` `1.5` `2` `2.5` `3` `3.5` `4` `5` `6` `8` `10` `12` `16` `20` `24`150151Below `4`, half-steps are legal and common (`gap-1.5` is the second most used152spacing class in the app). Above `4`, use whole steps only.153154**Never** an arbitrary spacing value. `p-[13px]`, `gap-[10px]`, `mt-[7px]` are155all rejected. There is no exception for "matching a design".156157### Canonical rhythm158159| Layer | Value |160| --- | --- |161| Page container | `mx-auto w-full max-w-2xl` |162| Section vertical padding | `px-4 py-10 pb-20 lg:py-20` |163| Header → body | `space-y-5` |164| Settings major sections | `space-y-8` |165| Tab panel content | `space-y-6` |166| Search + content block | `space-y-4` |167| List of rows | `space-y-2` |168| Panel inner padding | `px-4 py-5` (standard) · `px-4 py-3` (compact) · `px-4 py-2` (row) |169| Row internal gap | `gap-3` |170| Title ↔ meta gap | `gap-1.5` |171| Button group gap | `gap-2` |172173**Give every gap one owner.** A gap is produced by the parent's `gap`/`space-y`174*or* by a child's margin — never both. If you are adding `mt-*` to a child inside175a `space-y-*` parent, you have two owners and the rhythm is already broken.176177**Padding never sits on a bordered element that hosts flush children.** A panel178with a table or list running edge-to-edge puts padding on its inner sections, not179on the bordered box.180181---182183## Typography184185Roobert (`--font-sans`) for everything. Roobert Mono (`--font-mono`) **only** for186code, commands, paths, IDs, and keys. Mono is not a decorative choice.187188### The scale — these rungs and no others189190Kortix redefines the small end. `text-xs` is **13px here, not 12px**.191192| Class | Size | Line height | Role |193| --- | --- | --- | --- |194| `text-xs` | 0.8125rem / **13px** | 1.23 | Meta, captions, row descriptions — **the workhorse** |195| `text-sm` | 0.875rem / 14px | 1.43 | Body, row titles, labels, button text |196| `text-base` | 1rem / 16px | 1.5 | Long-form prose only |197| `text-lg` | 1.125rem / 18px | 1.56 | Rare — sub-headings |198| `text-xl` | 1.25rem / 20px | 1.4 | **Section page title** |199| `text-2xl` | 1.5rem / 24px | 1.33 | Detail-view title |200| `text-3xl`+ | 30px+ | — | Marketing only. Not in app chrome. |201202`text-md` (0.9rem) exists in `@theme` but is used 8 times in the whole app.203**Treat it as non-canonical — do not add uses.**204205### Weight and tracking206207| Need | Value |208| --- | --- |209| Body, meta | `font-normal` (default — do not write it) |210| Row title, label, active nav | `font-medium` |211| Detail title | `font-semibold tracking-tight` |212| Anything else | Not available. There is no `font-bold` in app chrome. |213214`tracking-tight` pairs with `text-2xl` and up. Never track body text.215216### Roles217218| Role | Exact classes |219| --- | --- |220| Section page title | `text-xl font-medium text-foreground` |221| Detail title | `text-2xl font-semibold tracking-tight` |222| Panel section label | the `Label` component (`text-sm font-medium`) |223| Row title | `text-sm font-medium` |224| Row meta / description | `text-xs text-muted-foreground` |225226**Never** `text-[11px]` or any arbitrary size. If 13px is too big, the element is227wrong, not the scale.228229---230231## Radius232233`--radius: 0.625rem` (10px) is the root. The ladder derives from it.234235| Class | Computes to | Surface |236| --- | --- | --- |237| `rounded-sm` | 6px | Status icon tiles, small chips |238| `rounded-md` | 8px | **Panels, rows, tables, cards, buttons — the default** |239| `rounded-lg` | 10px | Inputs and selects (via `variant="popover"`) |240| `rounded-xl` | 14px | Rare. Large media containers only. |241| `rounded-2xl` | 16px | **Marketing surfaces only.** Not in app chrome. |242| `rounded-full` | — | Pills: badges, avatars, pill buttons |243| `rounded-none` | 0 | Flush seam inside a disclosure |244245**Rules:**246- Default to `rounded-md`. If you are unsure, it is `rounded-md`.247- **No nested rounding.** A rounded child inside a rounded parent is banned — either the child is flush (`rounded-none`) or the parent is not rounded.248- **No `rounded-[…]`.** 58 exist in the codebase; add no more.249- Bare `rounded` (4px, a Tailwind default outside this ladder) is legacy. Prefer `rounded-sm`.250251---252253## Elevation254255**Verified at HEAD:** globals.css defines no shadow ladder. `shadow-*` renders256**stock Tailwind shadows**. (`kortix-design-system` describes a custom257four-sided ladder in `@theme` — that is stale; `grep -an "shadow-" globals.css`258returns only `--shadow-liquid-glass`.) The *semantics* below still hold.259260| Step | Use |261| --- | --- |262| **none — border only** | **Panels, rows, tables, cards. Anything in page flow is flat.** |263| `shadow-xs` | Chips, slider thumbs |264| `shadow-sm` | Sticky bars, segmented controls |265| `shadow-md` | Dropdowns, selects, popovers |266| `shadow-lg` | Modals, sheets, toasts |267| `shadow-xl` | Command palette, floating windows |268269- Elevation means **floats above the page**. If it sits in the flow, it gets a border.270- Overlays pair shadow **with** a hairline border: `bg-popover border shadow-md`.271- **Never `dark:shadow-*`.**272- **Never `shadow-[…]`** when a step fits.273274---275276## Motion277278**Kortix is a speed product. The motion budget is: fast, or none.**279280Animation here exists to make the UI feel *faster* and to explain *where a thing281came from*. It does not exist to be noticed. If a reviewer's first comment is282about the animation, the animation is wrong — they should have noticed the283interface, and felt that it was quick.284285Two rules carry most of the weight:286287> **The more you animate, the less each animation is worth.** If everything288> moves, nothing stands out.289>290> **Sometimes the best animation is no animation.** "Should this animate at all?"291> is a real question with "no" as a frequent, correct answer.292293### Pass 0 — the frequency ladder294295**Before choosing a duration, count how often a user sees this in a working296day.** Frequency sets the budget. Nothing else does — not how impressive it297looks in isolation, not how much effort the animation took.298299| Seen | Budget | Examples |300| --- | --- | --- |301| **Constantly** — tens of times an hour | **None.** `transition-none` | Keyboard list nav, row hover in a dense list, focus moves, selection in a table, anything driven by an arrow key |302| **Often** — several times a session | **100–150ms**, opacity + transform only | Dropdown, popover, tooltip, tab switch, disclosure, hover on a non-dense target |303| **Occasionally** — once or twice a session | **200–300ms** | Modal, sheet, drawer, toast, panel swap |304| **Rarely** — first run, or a moment worth marking | **≤300ms**, blur allowed | Empty → first content, a completed deploy, a destructive confirm |305| **Marketing only** | **up to 500ms**, one hero moment per viewport | Landing intro, feature illustration |306307**Keyboard-initiated actions are never animated.** A user drives them hundreds of308times a day; motion makes the interface feel laggy and disconnected from the key309they just pressed. This has no exceptions.310311### Easing blueprint312313| Curve | Use it for | Notes |314| --- | --- | --- |315| **`ease-out`** | **Enter and exit.** Anything appearing or leaving. | The default. Fast at the start = feels responsive. Reach here first. |316| `ease-in-out` | Elements **already on screen** that move, resize, or morph | Accelerate-then-decelerate, like a car |317| `ease` | Hover transitions of color, background, opacity | Gentler; correct for small state changes |318| `linear` | Marquee, progress, hold-to-delete, continuous rotation | Only where constant rate *is* the meaning |319| **`ease-in`** | **Nothing.** | Starts slow → the UI feels sluggish. Two uses exist in `apps/web`; add none. |320321`ease-in` and `linear` are the two ways a Kortix surface starts feeling slow.322Same duration, worse feel — easing changes perceived speed independently of time.323324### Duration325326**Product motion stays under 300ms.** Over 300ms is a marketing budget, or a bug.327328| Token | Value | Use |329| --- | --- | --- |330| `duration-fast` | 100ms | Hover color/opacity |331| `duration-normal` | 150ms | **The default UI transition** |332| `duration-moderate` | 200ms | Disclosure, accordion, tab |333| `duration-slow` | 300ms | Modal, drawer, sheet — the ceiling |334| `duration-slower` | 500ms | Marketing only |335336Two legitimate reasons to go longer, both requiring a comment saying which:337**a large element** (bigger reads as heavier, so it moves slower), or **a steep338custom curve** whose front-loading makes a longer duration still feel snappy.339"It looks nicer" is not one of them.340341> **Known drift:** raw values (`duration-150` ×104, `duration-200` ×67,342> `duration-500` ×23) outnumber tokens (`duration-fast` ×10, `duration-normal`343> ×5) by roughly 12:1. The tokens are correct and the raw values are debt.344> Write tokens.345346### Properties347348Animate **`opacity`, `transform`, `filter`, `color`, `background-color`** and349nothing else. These are the compositor-friendly properties; the rest cause350layout and drop frames.351352- **Never `transition-all`, and never bare `transition`.** Both animate every353 property that changes, including ones you did not intend, on every state354 change. Name the property: `transition-colors`, `transition-transform`,355 `transition-opacity`. (Outside `components/ui/`, `apps/web` has 26356 `transition-all` and 17 bare `transition` — do not add more.)357- `transition-[max-width,opacity]` and other **explicit** arbitrary property358 lists are fine — naming two properties is still naming them. The ban is on359 *unnamed* transitions, not on the bracket syntax.360- **Never animate `height`, `width`, `top`, `left`, or `margin`.** For height,361 use a grid-rows or `motion/react` height animation, not a CSS height362 transition.363364### Fixed values365366| Thing | Value | Why |367| --- | --- | --- |368| Press feedback | `active:scale-[0.96]` | The house press (169 uses). Makes the UI feel like it is listening. |369| Enter scale floor | `0.9`–`0.97` | **Never `scale(0)`.** Things do not come from nothing; a deflated balloon still has a shape. |370| Spring | `{ type: 'spring', duration: 0.3, bounce: 0 }` | `bounce: 0` is the brand. Buttery, never playful. |371| Bounce > 0 | Drag-release gestures only | A drag applied force, so a settle reads as physical. A click did not. |372| Blur bridge | `filter: blur(4px)` → `blur(0)` | Only to bridge a **state swap** where two objects would otherwise blink. Not decoration. |373| Popover origin | `transform-origin: var(--radix-…-transform-origin)` | Scale **from the trigger**. The `center` default is wrong and reads as a floating box. |374375### Hover, tooltips, and touch376377- **Hover effects must not move layout.** A hover that shifts position moves the378 element out from under the cursor and flickers between states. Change color,379 background, opacity, or `transform` — not position in flow.380- Hover is `hover:` (Tailwind v4 already gates it to hover-capable devices). Do381 not hand-roll a hover effect that fires on touch.382- **Tooltips:** delay before the first, then **no delay and no animation** for383 subsequent ones while the group is active. The delay prevents accidental384 activation; repeating it makes the UI feel slow.385386### Reduced motion387388Every animation ships two variants. `prefers-reduced-motion: reduce` means389*remove the movement*, not remove the feedback — keep opacity and color, drop390`transform` and anything that translates or scales.391392```tsx393// Tailwind394className="motion-safe:transition-transform motion-reduce:transition-opacity"395```396397```tsx398// motion/react399const reduce = useReducedMotion();400<motion.div animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0 }} />401```402403### Orchestration404405**One moving thing at a time.** Stagger is a marketing device. In product UI a406staggered list is strictly slower to read and to click than the same list407appearing at once — the delay is charged to the user, every time.408409410411---412413## Rejected defaults414415These are the shapes LLM-generated UI reaches for by reflex. All are wrong here.416Recognizing one in your own output means stop and re-pick.417418- **A value in brackets** — `p-[16px]`, `text-[13px]`, `gap-[10px]`, `rounded-[12px]`. The scale exists; use it.419- **A raw Tailwind palette color** — `bg-emerald-500`, `text-red-400`, `border-zinc-200`.420- **A hex, rgb, hsl, or oklch literal** in a component file.421- **A `dark:` variant for color, background, border, or shadow.**422- **A gradient** used as decoration. Kortix is flat.423- **A shadow on an in-flow card.** Border only.424- **`rounded-2xl` / `rounded-3xl` on app chrome.** That is a marketing radius.425- **Nested rounded containers** — a rounded box inside a rounded box.426- **Opacity on text for hierarchy** — `text-foreground/60`. Use `text-muted-foreground`.427- **`font-bold` in app UI.** The ladder stops at `font-semibold`.428- **All-caps eyebrow labels** and letter-spaced micro-headings.429- **A generic 3-up card grid** used because content had to go somewhere.430- **A colored alert panel** hand-rolled from `bg-red-50 border-red-200`. Use `InfoBanner`.431- **An icon spinning as a loader.** `Loading` is the only spinner. Never `animate-spin` on an icon.432- **A second border color.** There is one `--border`.433- **Emoji as UI iconography.** Phosphor only.434- **A new font.** Roobert and Roobert Mono are the entire type system.435436Motion:437438- **Animating because you can.** The default answer to "should this animate?" is **no**.439- **`transition-all` or bare `transition`.** Name the property.440- **`ease-in` or `linear`** on a UI transition. Both read as slow.441- **A duration over 300ms** in product UI.442- **Animating a keyboard-driven interaction** — arrow-key list nav, focus moves.443- **A hover effect that moves layout.** It flickers.444- **A staggered list** in product UI. The delay is billed to the user.445- **Entering from `scale(0)`** or `opacity` with a big `y` translate.446- **A popover scaling from `center`** instead of from its trigger.447- **A spring with `bounce > 0`** on anything that was not dragged.448- **Animating `height`, `width`, `top`, or `left`.**449- **Shipping one motion variant.** `prefers-reduced-motion` is not optional.450451---452453## Rationalization table454455Every one of these has produced a real regression. If you think one of them, stop.456457| Thought | Reality |458| --- | --- |459| "The design says 16px, so `p-[16px]`" | `--spacing` is 0.23rem. `p-4` is the translation. Bracket values desync this element from every other one. |460| "There's no token for this exact shade" | Then the shade is wrong. Pick the nearest role token. |461| "`emerald-500` is basically `kortix-green`" | It is not, and it does not flip in dark mode. Two greens on one screen is the bug. |462| "I'll add `dark:` just for this one" | Every `dark:` for color is a token you failed to use. It will drift the moment the theme changes. |463| "A subtle shadow makes the card pop" | In-flow surfaces are flat. "Pop" is not a requirement. |464| "`rounded-2xl` looks more modern" | It looks like a different product. `rounded-md`. |465| "It's just a one-off marketing-ish section" | App chrome and marketing have different radius/type rules. Name which one you are in, then follow that column. |466| "Tailwind's default spacing is fine here" | This app overrode it. Stock intuition is wrong by 8% everywhere. |467| "13px is too big for this caption" | The scale has no rung below `text-xs`. Change the layout, not the scale. |468| "I'll match the tokens later" | There is no later. The audit runs on this diff. |469| "The existing file already does it this way" | Legacy is not permission. Do not add the 26th `text-emerald-500`. |470| "It's inside a `cn()` so it's fine" | The audit greps source text. Location does not launder a raw value. |471| "A little animation here would delight the user" | The user has a goal, not a wish to be delighted. Delight that repeats becomes friction. |472| "It's only 400ms, that's still quick" | Product ceiling is 300ms. 400ms is a marketing budget spent in a product surface. |473| "The stagger makes the list feel considered" | It makes the list slower to read and slower to click, every single time. |474| "`transition` is shorter to type than `transition-colors`" | It animates properties you did not choose, including layout ones. Name the property. |475| "This dropdown animation looked great in the demo" | A demo is one viewing. Estimate the fiftieth. That is the real design target. |476| "I'll add `prefers-reduced-motion` later" | It is the second half of the animation, not a follow-up. Ship both variants together. |477| "`ease-in` looks smoother on the way out" | It starts slow, so the whole UI reads as sluggish. `ease-out` for enter *and* exit. |478479---480481## Red flags — stop and re-pick482483- You typed `[` inside a className for a size, color, spacing, or radius.484- You typed a color word that is not `kortix-*` or a semantic token.485- You typed `dark:`.486- You typed a number that came from a screenshot or a Figma inspector.487- You are adding `mt-*` to a child of a `space-y-*` parent.488- You wrote `shadow-*` on something that sits in page flow.489- You reached for `rounded-xl` or larger inside `apps/web` app chrome.490- You are about to say "close enough".491- You added an animation without first counting how often a user sees it.492- You typed `transition` or `transition-all` with no property name.493- You typed a `duration-` over `300`, or a raw `ms` instead of a token.494- You are animating something a keyboard drives.495- You wrote an enter animation and no `motion-reduce` / `useReducedMotion` branch.496- You are proud of how the animation looks. Check that it still feels fast on the fiftieth run.497498**All of these mean: go back to the allowlist.**499500---501502## Audit503504Run before claiming done. It is mechanical — do not eyeball it.505506```bash507.claude/skills/kortix-brand-guidelines/audit.sh apps/web/src/features/your-feature508```509510Zero output means clean. Every hit is either a fix or a written justification in511the PR body. `audit.sh` with no argument audits `apps/web/src`, which will report512the existing legacy debt — scope it to your own paths.513514---515516## Marketing vs app chrome517518Two columns, one brand. Name which one you are in before pass 3.519520| | App chrome (`apps/web` product surfaces) | Marketing (landing, changelog, docs shells) |521| --- | --- | --- |522| Max type | `text-2xl` | `text-7xl` |523| Radius | `rounded-md` | `rounded-2xl` allowed |524| Shadow | Overlays only | Large previews may use `shadow-2xl` |525| Density | Dense — `text-xs` workhorse | Open — `text-base` prose |526| Color | Semantic + `kortix-*` | Same. **No exception.** |527| Spacing scale | Same steps | Same steps |528| Motion ceiling | 300ms, one moving thing | 500ms, one hero moment per viewport |529| Stagger | Never | Allowed, once, on intro |530| Intro animation | Never | Once per visit — must not replay on back-navigation |531532Color, spacing, and the ban on `ease-in` never fork. Only type ceiling, radius533ceiling, density, and motion budget do.534535---536537## Checklist5385391. Named the surface: in-flow (border, flat) or floating (shadow)?5402. Read the closest reference implementation from `kortix-design-system`.5413. Every color is a semantic token or `kortix-*`. Zero `dark:` for color.5424. Every spacing value is a scale step. Zero brackets.5435. Every font size is a named rung. Zero brackets.5446. Radius is `rounded-md` unless the table says otherwise. No nesting.5457. Shadow only if it floats.5468. **Motion: counted the frequency first.** Constant → none. Product → ≤300ms, `ease-out`, token duration, named property, `active:scale-[0.96]`, reduced-motion variant shipped.5479. `audit.sh` is clean on your paths.54810. Checked light **and** dark by toggling the theme, not by reading the code.54911. Replayed the interaction ten times. It still feels fast, and you stopped noticing the animation.