Imported from Hckerson/next-setup (
.claude/skills/elegant-ui/SKILL.md). Install upstream withnpx skills add Hckerson/next-setup --skill elegant-ui. Copyright stays with the author.
Elegant UI
Reproduce the elegance, structure, layout, composition, and color sense of first-class UI — always rendered in this starter's own design tokens, never generic shadcn defaults. This is a reusable starter template: it ships the tokens and a minimal set of primitives; build the rest to the standard below.
Two modes
| Mode | Trigger | Output |
|---|---|---|
| Create | Component/screen does not exist yet ("build a…", "add a…", "make a…") | An elegant component written directly into components/ in the house language. |
| Upgrade | An existing component named + the word "upgrade" (or "elevate", "make it premium", "package it") | The re-elevated component, rebuilt against every rule below. |
If the mode is ambiguous, default to Create and say so.
Prime directive: translate, don't transplant
Learn structure, layout, composition, and interaction from any exemplar (a dribbble shot, a 21st.dev prompt, a pasted component). But every color, font, radius, shadow, spacing, and easing value must be a house token from the table below. A dark hardcoded bg-black text-neutral-50 reference becomes bg-sidebar text-sidebar-text-active. bg-card becomes bg-background-muted. Never emit a raw hex, a stock Tailwind palette color (neutral-800, slate-500), or a shadcn default token (bg-card, text-muted-foreground, text-foreground, bg-primary) — those tokens do not exist here.
The token firewall covers COLOR; a second firewall covers FORM. From any exemplar take only the information structure (what is grouped, what is ranked, what the interaction is); reject its period signatures on sight whatever the palette. A tokenized copy of a 2023 template is still a 2023 template. See Era below for the list.
House tokens — the only vocabulary
Tailwind v4, tokens defined in styles/tokens.css + @theme in styles/globals.css. Dark mode = .dark class variant (@custom-variant dark).
Color (prefix bg- / text- / border-):
background · background-alt · background-muted — surfaces
text · text-secondary · text-muted · text-inverse — foreground ramp
accent · accent-dark · accent-light — the primary brand / interactive color (CTAs, active, emphasis; use sparingly). The concrete hue is defined once in styles/tokens.css.
highlight · highlight-dark · highlight-light — the secondary/featured accent for "pop" moments only (hue defined in styles/tokens.css).
border · border-light · border-dark — 1px dividers
success · error · warning · info (+ each -light) — status only (note: it is error, not danger)
sidebar · sidebar-border · sidebar-hover · sidebar-active · sidebar-text · sidebar-text-active — the dark nav ramp
metric-pipeline · metric-success · metric-activity · metric-warning — metric/stat accents
overlay — modal scrims
There is no numeric ramp here. Brand and status color step by named suffix (accent / accent-dark / accent-light), never by -50…-900. A rule that needs "a light wash" means accent-light; "a solid max-contrast fill" means accent or accent-dark; "a dark tonal island" means bg-sidebar.
Type: families font-display (serif display) · font-body (sans body) · font-mono (monospace) — the concrete typefaces are wired once in public/fonts/font.ts and exposed via the @theme in styles/globals.css; never restate the family names here. Scale text-h1 72px · text-h2 56px · text-h3 40px · text-h4 30px · text-h5 24px · text-h6 20px · text-lg 18px · text-base 16px · text-sm 14px · text-xs 12px · text-label 14px · text-caption 11px. Weights font-light (300) … font-bold (700).
Named scale classes in styles/globals.css: .pad / .pad-x / .pad-y, .gap / .gap-x / .gap-y, .large-pad, .md-pad, .smooth, plus a .stat-card / .metric-icon / .metric-label / .metric-value / .metric-meta family. They are sized for the editorial register — in an application view state the step explicitly (gap-2, p-3) rather than defaulting to them. Beside them sit the application-register classes, and they are mandatory rather than stylistic — starter/token-first-classnames errors on every bracketed value and every responsive prefix inside a className, so these are the only legal spelling of what they express: .micro-label (the mono 10px uppercase eyebrow — it carries no colour, so append the tone yourself), .measure (the 54ch prose measure), .safe-t / .safe-b (env(safe-area-inset-*)), .collapse-grid (the 0fr↔1fr disclosure, flipped by data-open), .dot-grid, .focal-glow, .nav-floating, .context-rail. Anything else that would need brackets or a breakpoint prefix earns a named class here first.
Radius rounded-xs sm md lg xl full (xl = 12px, the ceiling). Shadow shadow-sm md lg xl 2xl. Motion easing ease-smooth (default), ease-out; duration duration-200 (hover) / duration-300 (layout). Z-index via tokens as arbitrary values (z-(--z-indices-modal), z-(--z-indices-popover), …) — there is no z-modal utility.
Structure rules
- Decompose aggressively. One job per component, one component per file —
starter/one-component-per-fileenforces both the count and the kebab-case filename match, so a screen is a shell that imports many small named pieces (WorkspaceSwitcher,NavItem,SectionTitle), each in its own file. Not one 300-line function, and not several components sharing a file. Section banner comments are unavailable —starter/no-commentserrors on every comment in the tree; the file boundary is the grouping, and a name that needs a banner needs a better name. - Data-driven rendering. Content lives in a typed array/map above the render; JSX maps over it. Declare the shape (
type NavItemData = { id: string; title: string; icon: React.ElementType; children?: NavItemData[] }). Recurse when the UI nests (an item renderer that renders itself forchildren). - Build primitives to the house standard, not shadcn's
data-slotstyle:forwardRef+ explicitHTMLAttributes<…>props +displayName; variants as typedRecord<Variant, string>maps composed withclsx(nocva— it is not installed). This starter ships only a minimal primitive set —components/ui/button.tsx— and nocomponents/ui/index.tsbarrel yet. Reuse those where they fit; when you need Card / Modal / Badge / Avatar / Table / etc., build them to this standard rather than pulling in shadcn defaults (and consider adding acomponents/ui/index.tsbarrel as they accrue). House gotcha: the existingButtonhas noasChild/Slot — for a link, wrap<Link>around<Button>— and itssizeaxis is fixed heights (smh-8·mdh-9·lgh-10·xlh-11) across three tones (accent·quiet·ghost); at row scale usesize="sm"rather than restyling it. - Controlled + uncontrolled duality where a parent may or may not own state:
const current = value ?? internal. clsxonly (no tailwind-merge). The codebase composes classes withclsx(import clsx from "clsx"), matchingcomponents/ui/button.tsx, and there is nocnhelper.classNamewill NOT override a base utility of the same CSS property — it just appends. Compose additively; put variability in typed variant maps, not in override-by-merge. Consequence for width/height overrides: a primitive whose base isw-fullcannot be narrowed throughclassName— wrap it in a sized box.- Icons from
@carbon/icons-react, absolute@/paths only (@/*→ project root). 4-space indent.
Layout & spacing
- Flex-column shells:
flex-1 overflow-y-autoscroll region,mt-autopinned footer, consistentgap-*. - One radius language per component; 1px borders are the only dividers —
border-borderfor row dividers,border-border-lightinside a plane,border-border-darkat structural edges (page, section, chrome boundaries). - App shell (nav bar / rail / toolbar / footer): chrome is exempt from both floors — the Craft 3 depth floor and the Craft 5 texture floor — as well as the alpha rule and the ceiling; a bar that needs a glow is a bar with nothing in it. Nav form follows the register, not taste.
- Animated collapse via
grid-rows-[1fr]↔[0fr]+overflow-hidden, not max-height hacks. - Precision only when the scale can't express it (
py-[7px],w-[18px]); otherwise the token spacing steps. Fixed geometry stated on the scale (h-9,h-11,size-8) is not arbitrary precision — it is the density spec (see the Application register below).
Color & elevation
- Establish hierarchy with the text ramp (
text-text→text-secondary→text-muted) and subtle surface shifts (bg-background→bg-background-muted), not with saturated fills. - Interaction/active state = a quiet wash + border, e.g.
hover:bg-background-muted hover:border-border, activebg-accent-light text-accent. Brand color used as decoration or emphasis appears rarely — a CTA, a badge, one focal number. Color used as DATA is not budgeted: the status ramp (success/warning/error/info), themetric-*accents, a progress fill, a category tone, a series color are readings of the row, and the TEXTURE floor requires them on every row that carries state. A column of uniformlytext-textvalues where the data has state is a flatness fail. - Restraint governs EMPHASIS in the field, not INFORMATION in the field. The quiet ramp covers ~90% of a view — quiet means low-contrast and unranked, it does not mean empty. Every field row still carries its artifacts (TEXTURE floor); they read quiet because they are small and token-toned, not because they are absent. The ONE hero element earns a bold moment: a solid max-contrast fill (
bg-accent text-text-inverse), a scale/span jump, tonal inversion of the whole tile (abg-sidebarisland — only where no dark rail is in the same view), or any Craft 3 depth device pushed one step further. Glow and gradient are the last resort — editorial register only, once per route. Timid-everywhere IS the flatness trap. See Craft below. - Elevation is restrained but not uniform: peers
shadow-sm, the focal elementshadow-md+ a tonal lift. Thehover:-translate-y-0.5 ease-smoothlift is scoped by Craft 4 — the ONE focal tile, or all-or-none across an equal-rank clickable set; every other hover istransition-colors. - Dark surfaces (nav rails, command palettes) use the
sidebar-*ramp.
Era — dated · saturated · structural
"Modern" resolves to the average of the training data; name the period instead. Scope words — every floor and ceiling is a quantifier over one of these: component = one file you emit · route = one page file and everything it imports · card = a bordered or rounded surface that owns a data region (a bare band — unbordered and unrounded — a table row, and page chrome are not cards; a bordered band is) · tile = a card in a repeated set (a KPI strip member, a grid cell) · row = one repeated record in a list, table or band · view = one route's content column, excluding chrome. Ceilings are per route — grep the route before claiming one. Atmospheric effect = exactly this set: backdrop-blur*, a blur-* glow, a masked dot-grid, a decorative bg-gradient-* wash. It is narrower than the effect budget's set, and animate-* is not in it.
DATED (2012–18) — grep your JSX and cut: centered eyebrow/text-h1/lede/twin buttons over a bg-gradient-to-r band — the editorial hero itself is legitimate; the gradient band, the CTA twins and the centered max-w-2xl measure are what date it · rounded-full filled CTA beside its outline clone · grid-cols-3 of identical rounded-lg shadow-md p-6 cards each topped by a size-12 rounded-full icon circle · text-center max-w-2xl mx-auto header + divider bar · logo-left / centered-links / CTA-right bar + 4-column dark footer · stacked full-width inputs with floating labels · zebra table with bold all-caps headers · centered max-w-lg modal, ghost-cancel + filled-confirm · empty state as oversized centered illustration + centered button · hover:scale-105, count-ups, fade-in-on-scroll on every section.
SATURATED (2021–24, the Framer/AI-landing uniform) — the canonical ban list; rejecting this is the point: fixed top-4 mx-auto w-fit rounded-full border bg-background/60 backdrop-blur-md pill nav · "✨ New" pill above a centered headline · gradient bg-clip-text tracking-tighter headline · rounded-full CTA with a trailing arrow · blur-3xl color blobs (especially symmetric pairs), noise layers, dot-grid on every surface · ring-1 + bg-*/5 + backdrop-blur + rounded-2xl on every card, modal and empty state so nothing is structurally distinct · bento wall with spans picked for tile shape · marquee "Trusted by" logo strip · dark violet/blue duotone with light mode an afterthought · animated gradient borders, cursor spotlights, shine sweeps, blur-sm → blur-0 scroll reveals.
STRUCTURAL (the target) = every rule already in this file, applied with zero effect layers: token-stepped opaque planes, hairline dividers, weight/case/color contrast, scarce brand accent but unbudgeted state color, dense data-bearing artifacts, application-register density. Four additions:
- Imagery carries the weight. One photo at its natural aspect (
w-full h-auto), full-bleed or spanning a real column, type set beside or beneath it atmax-w-[54ch]— no accent tint, no scrim under body or display type (a scrim under fixed chrome is the exception), no grid of equalobject-coversquares used as decoration. Three cases, not one: editorial — one photo, natural aspect,w-full h-auto; content — an equal-rank grid where each photo IS the content; identity artifact (application register) — a fixedsize-8 rounded-md object-coverthumbnail or a 32px avatar in a row, which is data-bearing and required by the TEXTURE floor, not decoration. - Editorial, structural: asymmetric — display type set LEFT in a narrow measure, one
text-lglede, ONE CTA.text-h1…h3and generous spacing stay correct in that register; centered + gradient band + twin CTAs is what fails. - Effect budget, per component: ≤2 total occurrences across {
backdrop-blur*,blur-*,ring-*(non-focus),bg-gradient-*,drop-shadow-*,animate-*}. Count them in your own output before finishing. Occurrences that encode a datum do not count — a gradient whose stop is bound to a value, a ring whose colour is bound to state, ananimate-*bound to a live status. The budget counts only occurrences that would look identical with the data removed. - Texture is structural; effects are not. A first-class dense screen looks busy because it carries avatars, thumbnails, meters, dots and state-tinted glyphs — each one a reading of its row. That busyness IS the target and is the opposite of an effect layer. Zero effect layers ≠ zero artifacts.
Precedence — two FLOORS and one CEILING. DEPTH floor (Craft 3): ≥2 planes, ≥1 a real surface change — measured per card. TEXTURE floor (Craft 5): ≥1 data-bearing artifact per row, ≥3 distinct kinds per view — measured per row. ATMOSPHERE ceiling (Era): ≤1 atmospheric effect per route, on the focal — measured per route. When they disagree, meet the floors structurally and the ceiling by deletion — never the reverse. Era never licenses a flat opaque card or a textureless row; Craft never licenses a second effect layer. The floors and the ceiling never trade: no quantity of artifacts buys an effect, and no effect substitutes for an artifact.
Translate the adjectives, never obey them. modern → pass the ERA gate · clean → fewer planes, never wider rows and never fewer data points · sleek / refined → application-register density (p-3, text-xs field, strokeWidth={1.5}) · elegant → one focal + font-display font-light + zero atmospheric layers (artifacts stay) · polished → hover / focus-visible / empty / loading / error states all present · premium → real photography, font-display font-light, tight geometry — whitespace is an editorial-register answer only; in the application register premium reads as density — never gold, gradient, or glass. If you cannot state the translation, ask which is meant. When a house rule fights the artifact, emit VETO: <rule> — <conflict> — <alternative> and build the alternative; complying badly (backdrop-blur on a dense data table, a glow behind a legal disclosure) is worse than a flagged deviation.
Craft — depth, focal hierarchy, composition (the anti-generic layer)
Token-correct ≠ designed. The #1 "AI-generated" tells are: undersized focal · too roomy · textureless rows · flat opaque fills · uniform grids. These are HARD RULES — output reverts to generic without them. "Too roomy" is enforced by the DENSITY gate, "textureless" by the TEXTURE floor (Craft 5).
- Hierarchy is a size RATIO, not decoration (the #1 gap). Give each card/row exactly one focal datum (number/price/title); the label→value jump must be ≥3 type steps (e.g. a
.micro-labelat 10px vs atext-h6value — five steps). In the application register the focal caps attext-h6(20px), peers sit attext-xs(12px) — the hero reads as hero because everything around it is tiny, not because it's big.text-h4andtext-h5are TITLE sizes (page and section) and never a card's focal datum: atext-h4value does not fit anh-20KPI band at all. Exactly one riser per card: atext-smname beside atext-lgprice is two risers and fails — demote the name totext-xsand separate it by weight/color. Price + 3 specs all attext-base= co-equal numbers fighting for focus. The focal number is solid, max-contrast (text-text/text-highlight-dark) — never gradientbg-clip-texton the primary datum (that's for an editorial hero headline only). The ≥3-step ratio governs CARDS and TILES. Inside a row, rank is carried by weight, colour, case and column position — never by size; a row's type sits at one step (text-xs). - Rank by scale + position, never by a badge. A "Featured/Recommended/Primary" pill may annotate but is not the ranking mechanism — the hero must still read as hero with every badge removed. Differentiation must be structural (scale,
col-span/row-span, an added data point), never cosmetic — a recolored clone with the same footprint is an automatic hierarchy fail. - Depth is a FLOOR, not a look — the flat opaque fill fails, and glass is not the fix. Clear the floor with ≥2 depth layers, at least one a real surface change: (i) tonal nesting — an inset
rounded-md bg-background-muted p-2around secondary metrics; (ii) imagery — a photo bleeding under a text plane; (iii) material — an opaquebg-background-altplane onbg-background. Shadow craft (one light direction,shadow-smfield vsshadow-mdfocal) and border-weight step (border-border-lightinside,border-border-darkat structural edges) reinforce a layer but never satisfy the floor alone. Step tone by swapping the surface token (bg-background→bg-background-muted→bg-background-alt); alpha (bg-highlight/8) is for tinting a plane with the row's accent, never for building one. CEILING: ≤1 atmospheric effect per route, on the focal element, named in your ERA line.backdrop-bluris legal only on a surface that genuinely overlays scrolling content (sticky chrome, modal scrim, popover) — blurring a surface that overlays nothing is a fake. Ablur-3xlglow blob: at most one per route, editorial register only, never a symmetric pair, colored from tokens (the.focal-glowclass, which is token-coloured), never rawrgba()/hex.ring-1is not a depth device — it is focus (focus-visible:ring-2 ring-accent), the focal's single edge treatment (ring-1 ring-highlight/20orborder-highlight/25, never both, and it is that element's one edge, not an extra layer), or a data-bearing edge (an avatar ring whose colour reads status, a meter track) — data-bearing rings are unbudgeted, and the never-both rule applies to one element's own edge, not to an element nested inside a bordered parent. A masked dot-grid (the.dot-gridclass) is allowed on one surface per route and never counts toward the floor. Depth and texture are orthogonal and neither pays for the other: the depth floor is about PLANES, measured per card; the TEXTURE floor (Craft 5) is about ARTIFACTS, measured per row. A row of avatars does not clear the depth floor, and abg-background-mutedinset does not clear the texture floor. - Composition follows the data's RANK STRUCTURE. Rank exists only if the typed data shape declares it (a
featured/rank/primaryfield, or an order the data explicitly fixes). No such field ⇒ equal-rank (listings, search results, table rows, a saved collection) ⇒ a uniform grid is correct and differentiation is content-borne (each item's photo, price, status); never invent a hero to satisfy this rule, and never pick a span for tile shape instead of content weight — that is the bento tell. When rank exists, express it structurally — acol-span/row-spanof ≥2 columns, a scale jump of ≥3 type steps, an added data point, or a different internal layout; a hero tile + a condensed sibling rail beats a tile mosaic, and peers must not mirror the hero (a stacked table-row: thumbnail + inline specs + right-aligned price). A table row cannot express rank — every structural lever here (span, scale jump, different internal layout) is unavailable inside a fixed-height row, so pull the ranked item OUT of the table: a hero tile above a condensed table rail, rank carried by the tile. Recoloring one row is the cosmetic fail. Forbidden either way:grid-cols-N+ afeaturedboolean that only recolors or adds a pill. Comparison tables: the lifted featured column gets-translate-y-2 + shadow-lgplus that column's singlering-1edge (neverscale-*— it desyncs the colgroup); one surface owner per featured band (fill the<col>OR the cells, never both). Hover lift (hover:-translate-y-0.5 ease-smooth) belongs to the ONE focal tile; on an equal-rank clickable set apply it to all or none — mixed lifting inside one set is the tell. Every other hover istransition-colors. - Texture is a FLOOR — every row carries a data-bearing artifact. A screen of pure text rows is the flatness failure that padding rules cannot catch, and it is what "restraint" degenerates into. Every row AND every card/tile carries ≥1 artifact that encodes a datum; a view fields ≥3 distinct kinds, each bound to a DIFFERENT field; one row carries ≤3 (beyond that the row is a chart). The field must be one a reader would act on — a sort key, an internal id, or a mixed-scale weight is not a dimension, and binding an artifact to one to reach the count is the same fail as decoration. Multiple renderings of one field count ONCE — a status rail plus a status badge plus a status-tinted glyph is one kind, not three, and a view carrying only those is textureless however many shapes it draws. An artifact that renders nothing does not count for the rows where it is null — if a field is nullable, the DENSITY line must name the fallback. Row controls — the selection checkbox, the row action button, the disclosure chevron — are not artifacts: they encode interaction, not data, and count toward neither the ≥1 floor nor the ≤3 cap. Page chrome (nav bar, rail, toolbar, footer) is exempt from this floor, as it is from the depth floor. The vocabulary — none of which this starter ships — is: avatar · thumbnail · status dot · status pill · meter · ranked bar · delta chip · selection box · sparkline (card scale only — a charting component mounts a responsive container with its own resize observer per instance, so never one per row in a long table; use a pure-CSS meter or an inline
<svg>polyline at row scale). The test is the value, not the shape: repetition is a fail only when the repeated element carries the SAME value in every row. An identical grey glyph down a column is decoration; that same glyph tinted per row by a state lookup is texture, and is required. A data-bearing artifact never counts toward the effect budget. If a surface genuinely has no artifact-bearing field (a raw audit log, a legal disclosure), emitVETO: TEXTURE — <surface> carries no artifact-bearing field — <alternative>rather than manufacturing decoration to satisfy the floor.
Decorative signature devices — a MENU, at most two per component. This cap governs devices carrying no datum; anything that encodes a value is an artifact under Craft 5, which is a minimum, not a cap. The menu: a size-6 bordered-circle icon chip (grid place-items-center rounded-full border p-1.5) where the component's radius language already allows a circle — never an identical chip on every row, though a chip whose glyph or tone varies per row is data-bearing and required. A mono eyebrow .micro-label text-text-muted carrying a NEW dimension (count/status/timestamp), never restating the title — an eyebrow is a per-card label, not a per-route texture: cut it where it repeats identically across cards. A state-tinted chip cluster. Signature rules — always: Encode state in the data layer via a token lookup (today→text-highlight, overdue→text-warning), not uniform text-text-muted — this lookup is also how the texture floor gets met. Chips derive tone from the row's accent and cap with a +N overflow. In the application register a row carries NO prose — descriptive text has ONE home per card (a lede slot in the header, or the detail surface the row links to), never a second line inside each row; row text is single-line truncate + max-w-*, and line-clamp-2 belongs in that one prose slot. In the editorial register the ordering inverts: never truncate the only descriptive line — line-clamp-2 or cut it. Snap to the scale — off-token size={17}/py-[13px] is a craft fail.
Self-check before done: Name the ONE focal datum — is its label→value gap ≥3 type steps? Remove every badge — does the hero still read as hero (scale/position)? Name your two depth layers and the device that produced each — is at least one a real surface change, and if backdrop-blur appears, does that surface overlay scrolling content? Strip every blur/glow/ring — does the ranking still hold? (diagnostic: restore a layer only if hierarchy already held without it.) Does the layout match the data's declared rank? Name the data-bearing artifact and the FIELD it reads for every row AND every card — does each have one, does the view field ≥3 kinds bound to ≥3 different fields a reader would act on, and does each still render when its field is null? Delete the data and look at the row skeleton — what remains that still varies? (Nothing varying ⇒ the artifacts were decoration, not texture.) Count the lines in one row and the columns across it — is it one line and ≥4 columns, and is every prose sentence in the card's single prose slot? Grep your emitted JSX against Era → SATURATED; every hit must be the one construct named in your ERA line, and the effect budget is ≤2 after excluding data-bearing occurrences. Any "no" → rebuild structurally before finishing.
Typography & density — two registers
The type scale is marketing-grade (text-h1 72px · text-h2 56px · text-h3 40px · text-h4 30px · text-h5 24px · text-h6 20px) with airy 1.75 body leading. It is NOT one size for every context — pick the register, or dense app UI reads oversized (the #1 "everything feels big" cause here).
-
Editorial register — landing / marketing / hero / section headers / big empty states. The statement:
text-h1…text-h3 font-display font-light, generous spacing,text-lglede. -
Application register — the DEFAULT for dashboards, cards, panels, tables, sidebars, forms, menus. Dense by default:
- Geometry (hard) — padding is necessary and NOT sufficient: rows
h-9(36px) text-only,h-11(44px) when carrying an avatar/thumbnail; one line per row, always; table headerh-9, toolbarh-11, card footerh-9. A stat band packs N stats into ONEh-14band withdivide-x divide-border— never N cards. A metric/KPI card is ONE band — value left, trend artifact right, delta and caption beneath; max 3 lines,h-20. The row height is a content-box budget, not just an outer height: cellpy-*is subtracted from it, so anh-11row minuspx-3 py-2leaves 28px and a 32px avatar or button does not fit — artifact and action cells runpy-0and center. Rows never grow to fit content — content is chosen to fit the row. - Column floor — count the TYPE, not the JSX: a table row renders ≥4 typed columns if its type declares ≥4 renderable fields; a stat band carries ≥3 stats. Relabeling a table as a list is not a remedy — downgrading to a list card is legal only when the type declares fewer than 4, and the DENSITY line must then show the arithmetic (
columns 3 of 3 typed fields — list, not table). If the type is one field short, the fix is a lookup that promotes an existing enum to a labelled column, or a new field — not a narrower surface. - Padding (the ceiling, once the geometry is set): dense rows/cards
p-3(gap-2, maxgap-3); table cellspx-3 py-2.p-4is for the ONE featured/hero tile only — neverp-5+on a card body here. - Type — compact field, one riser: the field (rows, cell values, names, labels, ledes, control labels — the vast majority) sits at
text-xs(12px); secondary/meta staystext-xsand drops by color (text-text-secondary/text-muted); micro-labels are the.micro-labelclass (mono, 10px, uppercase, tracked — nottext-caption, which is 11px and non-mono, and not a bracketed size, which the lint gate rejects). Above the field, only titles (pagetext-h4· sectiontext-h5, nevertext-h1…h3, whichstarter/application-register-densityerrors on) and exactly ONE focal value per card (text-lg→text-h6) rise — a card's own title is NOT a section title: card titles sit attext-xs font-semibold, and the per-card riser cap counts DATA elements only. Differentiate names/labels/values by weight (font-semibold), color (text-textvstext-text-secondary), and case (mono-uppercase eyebrow) — never by size.text-smis banned for non-focal text — one exception:<input>/<textarea>/<select>value text sits attext-sm(14px) minimum, which is exactly where the shipped forms primitives sit; 12px form text is a legibility fail. Their labels staytext-xs, above the field, on one shared hairline baseline. A lede/subtitle staystext-xsand must never outrank the content it introduces. - Icons: glyphs (check/minus/inline detail)
size-3(12px); chip icons in asize-6circle; only a hero/identity icon reachessize-4.strokeWidth={1.5}. Note.metric-icon svginstyles/globals.csshardcodes 18px, which is off-token — don't propagate it into new components. - Artifacts: every row carries ≥1 data-bearing artifact (Craft 5) — a 32px avatar, a
size-8 rounded-md object-coverthumbnail, asize-1.5status dot, a status pill driven by a tone lookup, anh-1.5 w-16meter, a delta chip. Sparklines are card scale only. This starter ships none of these — build them, and say you are building them. - Rhythm: vertical spacing via one
flex flex-col gap-*, not stackedmt-5/mt-6.gap-4is the LARGEST gap in an application view, not its default: rows/elementsgap-2, card-to-cardgap-3, section-to-sectiongap-4; section paddingpy-4(py-6for the route's first section).py-10andgap-8are editorial values — they are how a dashboard becomes a scroll, and.pad/.gap/.large-padare sized for that register, so state the app-register step explicitly rather than defaulting to them. Ban the decorativerounded-full place-items-centericon circle (a generic glyph repeated identically down a column); arounded-fullelement carrying data — avatar, initials, status tone — is exempt and is the default way to meet the TEXTURE floor. Do NOT usetext-h1…h3ortext-lgbody leading here.
- Geometry (hard) — padding is necessary and NOT sufficient: rows
-
The refined signal is weight, not size:
font-display font-lighton whatever heading the register calls for — atext-h5in the light display face still reads as elegant. Body/labelsfont-body; keyboard hintsfont-mono. -
In the application register, always guard overflow: row text is single-line,
truncate+ amax-w-*, and the card's ONE prose slot is the only place text wraps. In the editorial register the ordering inverts:line-clamp-2for descriptive lines,truncateonly for single labels/tokens (never the sole descriptive line) — pair with amax-w-*.VETO:out when an application row's only content is prose.
Motion, icons, a11y (defaults, not extras)
- Everything interactive transitions (
transition-colors/transition-all duration-200 ease-smooth). Entrances are scoped: overlays (modal, popover, menu, toast) useanimate-in fade-in zoom-in-95— the scale reads as emerging from its trigger. Content (cards, rows, sections) usesfade-inalone, or nothing. Never stagger- or blur-reveal a grid on scroll. Don't add reduced-motion guards —styles/globals.cssandstyles/tokens.cssalready do it globally. - Carbon icons sized by Tailwind class, never the
sizeprop (size-3/size-4set CSS width/height and override the SVG attributes; the glyphs are fill-based, sostrokeWidthdoes nothing) — application register:size-3(12px) inline,size-4for identity/hero only;size-5editorial only; colored via the text ramp. - a11y:
aria-labelon icon-only buttons,sr-onlylabels,select-noneon chrome, realfocus-visible:ring-2 focus-visible:ring-accent,type="button"on non-submit buttons, keyboard handlers for overlays (Esc to close). A sortable table header needsaria-sort— there is no sortable-header primitive here, so build it or don't imply sorting. - Imagery: distinct real photos, one per item — NEVER a single repeated local placeholder like
/images/hero.jpg. Centralize a curated pool inlib/data/sample-images.ts(create it) and pull from it (don't scatter hardcoded URLs across pages). Good free sources: Unsplash (images.unsplash.com/photo-…?w=800&q=80) and Pexels (images.pexels.com/photos/…) — large, high-quality free photo libraries — and loremflickr (loremflickr.com/800/600/{keyword}— dynamic, keyword-based, no IDs; swap{keyword}for the domain's subject). This starter has noimages.remotePatternsinnext.config.tsyet — add the host there before using any remote image, ornext/imagewill reject it.
Create workflow
-
Restate what's being built + where it lands (
components/ui/*for primitives,components/common/*or a feature folder undercomponents/for composed sections). A routepage.tsxis not a home for either —starter/page-composes-onlyerrors on a component or a data array defined inside a route file; sections go tocomponents/, seed data tolib/data/, and the page composes them. -
Sketch the decomposition (shell + named parts) and the typed data shape.
-
Gate, then build. Emit these four lines as plain chat text immediately before the first code block — one gate per component file, never as a code comment, never inside the integration scaffold:
FOCAL — <datum> at <token>; field at text-xs; gap ≥3 steps(orFOCAL — none; equal-rank set, differentiation is content-borne) ·ERA — rejected <construct> on <element that appears in this JSX>, using <replacement>·DEVICE — <layout device + spans>(orDEVICE — uniform grid, equal-rank, justified by <scan/compare task>) ·DENSITY — rows <h-token> × <n> visible; <k> of <t> typed fields as columns; prose in <named slot> only; artifacts: <kind→field>, <kind→field>, <kind→field>.FOCAL, DEVICE and DENSITY must name things present in the emitted JSX — a line pasteable into another project is a failed gate. The
<h-token>you name must appear verbatim as a class on the row element — or, for a rowless component, on the card root (claimingh-9while shippingpy-2.5is a failed gate), and each artifact must name which field it reads — two artifacts reading the same field are one kind. Sanctioned alternate branches:FOCAL — <datum> ranked by <weight|color|case|column>; row register, no size jump·DENSITY — no rows; <component kind> at <h-token>; prose in <slot> only; artifacts: <kind→field>. No branch drops the<h-token>or the artifacts list — those two clauses are unconditional. AnyVETO:line substitutes for the gate line it overrides — a vetoed TEXTURE floor is declared, not silently dropped. When an artifact you name does not exist yet, say so and build it — this starter ships none of them, and citing a primitive that isn't there is a failed gate too. Then build in house tokens per the rules above. -
Compact-field pass (route-wide, up front): grep the route for
text-sm|text-base|text-lgand demote every non-focal, non-title hit totext-xs, leaving exactly one focal value per card. Do this comprehensively in one sweep — never element-by-element. -
Self-check against the Craft self-check and Anti-patterns. The rules that catch this skill's own material are
starter/token-first-classnames(no bracketed value or breakpoint prefix in aclassName),starter/application-register-density(the spacing ceiling and the title scale),starter/no-comments,starter/one-component-per-fileandstarter/page-composes-only. A gate line whose JSX breaks one of these rules is a failed gate.
Upgrade workflow
- Emit the FOCAL / ERA / DEVICE / DENSITY gate (Create step 3) — token-only or copy-only edits that change no structure are exempt from FOCAL / ERA / DEVICE; DENSITY is never exempt, because a spacing-only edit is precisely a density change. Then read the existing component and re-elevate it against every rule above (decompose, tokenize, add motion/a11y, tighten type & spacing) — including the compact-field pass: grep the component/route for
text-sm|text-base|text-lgand demote all non-focal, non-title text totext-xs, one focal per card, in a single comprehensive sweep. - Produce a
demo.tsxexercising it with realistic data.
Anti-patterns (reject on sight)
- Any raw hex, stock Tailwind color, or shadcn default token (
bg-card,text-muted-foreground,bg-primary,neutral-*,slate-*). - Referencing tokens from another project (
bg-bg,bg-bg-secondary, a numeric brand ramp likeprimary-600/secondary-500,danger,font-sans,ease-outas the house default,text-2xlas the focal cap) — none of those are this starter's vocabulary. - A monolithic component that should be decomposed; JSX with content hardcoded inline instead of mapped from typed data.
- Relying on
clsx/classNameto override a base utility (no tailwind-merge here) — narrow aw-fullprimitive with a sized wrapper instead. - Citing a primitive this starter does not ship (Card, Modal, Badge, Avatar, DataTable, Sparkline, ProgressBar) as if it existed — build it, and say you are building it. The converse is the same failure: rebuilding
Button's size or tone axis, or hand-rolling an eyebrow that.micro-labelalready names. - Thick default icon strokes; missing hover/focus states; saturated fills where a text-ramp/surface shift would do.
- A uniform grid/stack of identical cards or rows on RANKED data, with no elevated focal element — the #1 generic tell. Genuinely equal-rank sets (listings, search results, table rows) are exempt — see Craft 4.
- Flat opaque fills with no depth — none of Craft 3's three surface devices present (tonal nesting, imagery layer, material plane).
- Equal columns + identical spacing on ranked data instead of a composition (comparison table, asymmetric split, featured tile) — and its inverse: a manufactured asymmetry imposed on equal-rank data.
- A recolored/badge-flagged "hero" that shares the peers' footprint — differentiation must be structural (scale/span), not cosmetic.
- Gradient
bg-clip-texton a primary datum (price/KPI), or a "Featured/Recommended" pill used as the ranking mechanism. - A totally flat card — one opaque fill with nothing layered on it: no
bg-background-mutedinset, no imagery under the text plane, no second material plane. A drop-shadow, a border or a dot-grid does NOT clear the floor. Equally a fail: reaching forbackdrop-bluror a glow to fix it. p-5+on a dense card body, or a uniformsize-4decorative glyph on every row — the one-notch-loose density smell.- A textureless row — a row of pure text where the data has identity, state, ratio or trend to show. The commonest form of the flat screen, and the one padding rules never catch.
- A label stacked above its value inside a row, or a prose
detailline under every row title — both double the row for one datum. Prose gets ONE home per card. - A row taller than
h-11. And a surface rendering fewer columns than its type declares renderable fields — relabeling the table as a "list" to duck the column floor is the escape hatch, not the fix; the DENSITY line must show<k> of <t> typed fields. - Three renderings of ONE field counted as three artifact kinds (a status rail + a status badge + a status-tinted glyph is one kind) — the texture floor counts fields, not shapes.
- A metric/KPI card whose only artifact is a delta chip — which renders nothing when the delta is null. If the type carries neither a series nor a ratio, don't ship the card:
VETO: TEXTURE — <metric> has no series or ratio — add a series to the type, or fold it into the stat band. - N cards where a stat band belongs — peer stats of one subject go in one
h-14 divide-xband. The shipped.stat-cardclass stacks icon → label → value → meta withheight: 100%and no cap; that is four bands, not one. - A sparkline per row in a long table — each mounts a responsive container with its own resize observer; use a pure-CSS meter or an inline
<svg>at row scale. - Non-focal text at
text-sm+ — names, cell values, ledes, control labels leaking above the 12px field (input/textarea/select value text is the one exception —text-smis its floor). The field istext-xs; only titles and the one focal value rise, and non-focal text differentiates by weight/color/case, never size. - Two risers in one card — a raised name AND a raised price (or any second emphasized size). Keep exactly one focal per card; the rest sits at
text-xs. - Truncating the sole descriptive line in the editorial register; off-token arbitrary values (
size={17},py-[13px]) — and note the lint gate rejects every bracketed value in aclassName, so a new one needs a named class instyles/globals.cssfirst. data-slotshadcn-style primitives instead of the houseforwardRef+displayName+ variant-map pattern.- Deep-relative imports (
../../../) instead of@/. - JSX emitted with no
FOCAL/ERA/DEVICE/DENSITYgate lines, or a FOCAL/DEVICE/DENSITY line pasteable into another project. - A tokenized period template — see Era → SATURATED for the list. Palette compliance is not era compliance.
animate-in zoom-in-95on page content (overlays only), any scroll-triggered stagger/blur reveal, orbackdrop-bluron a surface that overlays nothing.