Styling — UnoCSS Attributify Mode (MANDATORY)
SkillDev toolsEsposter UnoCSS Attributify Mode styling conventions — prop-based attributes for all static styles, class only for scoped CSS refs / dynamic bindings / third-party selectors, theme primitives and theme colours over bespoke values, the MD3 typography set and semantic opacity in place of a fixed text-gray, text-info links, hover:bg-hover over a hand-picked surface, state variants instead of scoped &:hover blocks, the parent owning spacing (gap/padding over child margins), absolute positioning within a container, rem over px with its narrow exceptions, and style-block rules — plus deep dives on the utility vocabulary (slash/fraction values in valued attributify, abbreviated utilities, an equal w-/h- pair collapsing to size-, named over numeric, and gap directionality), page/panel/sidebar/region layout, border utilities and ownership including the banned global border reset, sentence-like rows in inline flow, images as NuxtImg, and arbitrary bracket values (calc, CSS variables, transitions, !important). Apply when writing or reviewing styles in .vue or .scss files, or laying out a page, panel, sidebar, or border.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Styling — UnoCSS Attributify Mode (MANDATORY) skill
What this skill tells your AI
The instructions your AI receives, as published by esposter/esposter in .agents/skills/styling/SKILL.md and read by ahel’s review.
Deep Dives
references/layout.md— when laying out a page, panel, sidebar or column split, sizing a region, drawing a border or finding one you did not ask for, or building a row that reads as one sentence.references/utility-vocabulary.md— when two spellings say the same thing: an abbreviation, a named step against a numeric one, a directional gap, or a slash value.references/images.md— when adding or sizing an image.references/arbitrary-values.md— when a utility needs an arbitrary[...]value:calc(), a CSS variable, a transition, or!important.
Core Rules
- Prop-based styling for ALL static styles:
<div text-red p-4>. Where UnoCSS attributes sit relative to component props is thevueskill's template attribute order. flexnotd-flex.sizeattribute (orwidth/heightprops) instead ofw-<n>/h-<n>where possible. They are authored lengths, so they takerem—size="4rem", neversize="64", which Vuetify renders aspx.- Prefer simple named utilities over arbitrary values. Avoid arbitrary shadows, gradients, dimensions, border widths, and z-index unless the layout needs them. Don't add z-index defensively; rely on DOM order and positioning first.
- Prefer theme primitives over bespoke styling:
StyledCard/v-sheetfor card/panel/surface backgrounds; theme colours (bg-background,b-border,text-primary,text-error) and semantic opacity utilities before custom colours. For surface colour usev-sheet, notbg-surfaceon a<div>(references/layout.md). - Avoid arbitrary hex/RGB/RGBA, custom shadows, and one-off background/border colours in app UI. If a semantic colour is genuinely needed, prefer Vuetify theme colours or the Material palette with lighten/darken variants (
text-green-darken-2,bg-yellow-lighten-5,text-red) over raw values. - Never hardcode a fixed dimension to lay out a region (sidebar/panel/column split) —
references/layout.md. Arbitrary dimensions are a last resort for true format constraints (aspect-video, viewport-safe containers, canvas/game surfaces, third-party embeds); first check whether the component hierarchy or flex/grid structure is wrong. - Always
rem, neverpxfor every authored CSS length — style blocks,:roottokens, inline style objects, arbitrary[...]values. Zero takes no unit (bottom: "0"). Utility names are not authored lengths, so scale tokens (p-4,top--1) andb-{n}widths keep their canonical form.pxsurvives only where the unit is not ours to choose: a value staying numerically in step with a JS API ($grid-breakpointsagainstuseDisplay().thresholds, a drawer width also passed as:width), SVG user-space attributes, HTML email, and vendored output mirrored into a snapshot.- A round
pxthat is already a token is a duplicated constant first —borderRadius: "4px 0 0 4px"wantsvar(--border-radius), not"0.25rem".
field-sizing: contentis an attributify utility — putfield-sizing-contentdirectly on the<input>/<textarea>, never in a scoped class.
What stays in class="..."
Only when technically required:
- Scoped CSS refs — class names referenced in
<style scoped>(e.g.class="card") - Dynamic bindings —
:class="..."always stays as-is, and a valueless utility switched on a condition belongs there rather than in a bound attribute.:py="isCompact ? 0.5 : 1"is fine: the extractor reads the literals and emits[py~="0.5"]and[py~="1"].:op-loading="isLoading ? '' : undefined"is not — an empty string is no value, so nothing is emitted and the attribute lands on a rule only when some unrelated file happens to write that utility bare. It fails silently and comes back the day that file changes.:class="isLoading ? 'op-loading' : undefined"emits the class and depends on nothing - Third-party component classes — e.g.
vue-flow__panel,v-window__controls,fc-event-title, Vuetify internalv--prefixed classes (e.g.v-theme--light) - SVG classes — e.g.
fclass1,a,b group— UnoCSS group variant token; must stay inclassso descendantgroup-hover:variants work
A scoped class (with v-bind() for reactive values) also stays correct where attributify cannot reach: structural pseudo-selectors (:nth-child, :not(), :first-of-type), :deep() rules, bare element/tag selectors, and non-colour reactive values (transform, top, height, fill, animation). Everything else — a class that only sets a theme colour, a hover colour, or arbitrary-value properties — is an attribute.
What can be attributify (including Vuetify utilities)
presetAttributify() is active in uno.config.ts, so ALL of these work as standalone attributify attributes:
- Vuetify MD3 typography: every role in
typographyPresets.md3is a shortcut, kebab-cased — the five scales (display,headline,title,body,label) crossed withlarge/medium/small.uno.config.tsgenerates them from the preset, so there is no shorter allowlist to check against. Never MD2 utilities liketext-caption; use the MD3 equivalent (text-body-small). - Vuetify theme colours:
bg-surface,bg-background,bg-border,text-error,text-info, etc. - Opacity emphasis: use
op-medium-emphasis/op-high-emphasis, nottext-medium-emphasis— and not the number either.op-60is--v-medium-emphasis-opacity's default written out a second time, so it stops tracking the theme the moment that variable moves; the numeric scale is for what is genuinely not emphasis, like theop-0→group-hover:op-100of a reveal. De-emphasised text is an emphasis opacity, never a grey —text-grayresolves against preset-wind4's palette rather than the Vuetify theme, so it paints one fixed grey in both themes and drifts out of contrast in one of them, whileop-medium-emphasisappliesvar(--v-medium-emphasis-opacity)over the current text colour. Paired withtext-body-small(captions, timestamps, hints) it already has a name:text-hint. - Custom theme colours:
bg-surface-opacity-80,bg-background-opacity-40, etc. - The preset's own palette:
text-amber,text-orange,bg-sky, etc. Vuetify's palette names are not the same set —bg-deep-purpleandtext-yellow-darken-4come from$color-pack, which emits classes only, so as attributes they generate nothing. Generate the CSS if unsure; a palette name that matches no rule fails silently.
Only registered colors generate utilities — don't assume a Vuetify default theme colour works: use text-primary not text-success, bg-surface not bg-surface-variant. Registration rules (theme colours, palette, safelisting) live in the unocss skill. When reading hyphenated theme colours from a colours store, destructure quoted keys and alias to camel-case: const { "hyphenated-key": hyphenatedKey } = storeToRefs(colorsStore);.
Links use text-info (the blue), never text-primary
Hyperlinks / clickable inline text get text-info — that is the conventional link blue, underlined on hover rather than always (hover:underline). text-primary is the brand/action accent, not a link colour. It applies to NuxtLink, NuxtInvisibleLink and every inline "click here" affordance, whichever of them a case calls for.
Inline text that runs an action rather than navigating is StyledActionLink, never a hand-styled span or a raw <a>. A raw <a> is lint-banned, and hand-styling the span means re-deciding the colour, the hover underline, the pointer and the whole keyboard-and-role wiring at each call site — which is how one of them ends up unfocusable, or bold-with-a-hover-underline where its twin is permanently blue and underlined. It takes a @click and its children are the words in the sentence: <StyledActionLink @click="isOpen = true">create one</StyledActionLink>. A link that navigates stays a NuxtLink.
State variants are utilities, not &:hover blocks
A colour that changes on hover/focus/disabled is a variant utility (hover:text-primary-darken-1, focus-within:b-info, disabled:op-30), never a scoped &:hover rule. Colons inside attribute names are valid in Vue templates — only a leading : triggers v-bind.
A hover or active background is hover:bg-hover / bg-activated, never a hand-picked surface colour. Both are defined in uno.config.ts from the same calc(var(--v-<state>-opacity) * var(--v-theme-overlay-multiplier)) formula VBtn uses, so a custom affordance lands on exactly the colour a real button does and follows the theme when those variables move. hover:bg-surface instead is a shade off every button beside it, invisibly until the two sit together.
The tint is an overlay over whatever is underneath, not a palette: a control whose background is itself the design — a chip swapping its own fill to read as selected — keeps its explicit colour.
Utility vocabulary — references/utility-vocabulary.md
Where two spellings say the same thing, one is the repo's. Choosing between an abbreviation and its long form, a named step and a numeric one, a directional gap, or a slash value against a bare one is that page.
Images Are <NuxtImg> — references/images.md
<v-img> and raw <img> are both vue/no-restricted-html-elements errors. Read the page when adding or sizing one: width/height are html attributes rather than styles, sizing is CSS utilities, and object-contain/object-cover is stated wherever both dimensions are constrained.
The Parent Owns Spacing
Space between siblings belongs to the container, as gap-*. Space inside a boundary belongs to that boundary, as padding. A child should not carry a margin to position itself against its siblings — it can't know what it sits next to, so the same margin gets re-solved in every component that renders it.
Three reliable signals that a margin is in the wrong place:
- A reset undoing a default (
mb-0,class="m-0") — the child is fighting spacing it should never have had. Fix the owner, don't stack a counter-margin. - A negative margin (
ml--2,my--1) — the parent's padding and the child's margin are fighting; one of them is wrong. - The same margin in sibling files (
<v-icon mr-2 />repeated across rows) — that's one gap the row should own, not N margins.
Margin stays correct for a few things: pushing an element within an already-gap-ed row (ma-auto, mt-auto), and off-scale nudges that aren't sibling rhythm at all — though reach for absolute positioning first. When converting a child margin to a parent gap, check the trailing edge: a mb-* on every child also pads below the last one, which gap-y-* deliberately does not. If that trailing space was load-bearing (scroll breathing room), move it to the container's padding, don't reintroduce the margin.
Absolute Positioning Within a Container
Use relative on the parent and absolute top-0 right-0 (or other corners) to pin UI elements. Prefer this over manual margin/padding tricks when an element should float independent of sibling flow.
Style Block
- Use
<style scoped>—scopedalways required, enforced byvue/enforce-style-attribute. A genuinely global block (transition classes targeting slotted/teleported content, overrides for third-party DOM appended todocument.body, or a child component whose root is a Fragment) carries an<!-- eslint-disable-next-line vue/enforce-style-attribute -- <reason> -->comment naming why scoping cannot reach the target. Keep that comment on one line —disable-next-lineabove a wrapped comment points at the comment's own second line, and the rule still fires. - A Fragment-rooted child defeats
scopedand:deep()alike, silently. Vue passes the parent'sdata-v-scope id onto a child component's root element only when the child has exactly one root; with a Fragment it applies the id to nothing.scopedthen compiles to.x[data-v-a]and matches no third-party node, and:deep()compiles to[data-v-a] .xwith no ancestor to anchor to — both produce valid CSS that never matches, so nothing errors and the style just does not apply. Check the child's compiled render forcreateElementBlock(Fragmentbefore assuming a selector is wrong (survey-creator-vue'sCreatoris one). Wrapping the child in a plain element restores:deep()by giving the id somewhere to land, at the cost of a DOM node that exists only to host an attribute; an unscoped block with the directive above is usually the better trade. - Library CSS is imported in script setup (
import "grapesjs/dist/css/grapes.min.css";), never via an unscoped<style>@useblock — the import stays code-split with the component and leaves no global style block behind. - Omit
lang="scss"unless the block uses Sass features (variables, nesting, mixins). Plain CSS doesn't need it. - Switch to
lang="scss"the moment nesting simplifies the block — many:deep(...)rules repeating one root selector collapse to one nested root. If a plain-CSS block repeats an ancestor selector 3+ times, refactor it to nested SCSS; conversely never addlang="scss"to a block that stays flat.
Signals
- GitHub stars
- 23
- Forks
- 3
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
styling-esposter- Source
- github.com/esposter/esposter