The as-* Shortcut System
Every visual rule in @atscript/vue-form, @atscript/vue-table, and @atscript/vue-wf lives in a single shortcut tree under packages/ui-styles/src/shortcuts/. Components emit as-* class names; the shortcuts turn those into vunor-primitive compositions. Once you know the tree, theming and per-state tweaks happen in one place instead of scattered across your stylesheet.
Naming convention
Two rules cover the entire tree:
as-<concept>is the base shortcut. Single source of truth for that concept's visual signature.as-<case>-<concept>is a case-specific variant that extends the base.
For example, descriptive helper text:
// shortcuts/common/index.ts
"as-description": "text-callout text-current/60 m-0",// shortcuts/form/as-field.ts
"as-field-description": "as-description -mt-[0.2em]",// shortcuts/form/as-form.ts
"as-form-description": "as-description",Consumers can repaint every description in the UI by overriding as-description alone — and every variant inherits the change. Same idea for as-overlay / as-form-overlay / as-table-query-overlay, as-close-btn / as-dialog-close, as-cell-* row in tables.
Shortcut groups
Shortcuts are split into five logical groups. They merge into allShortcuts (what asPresetVunor registers by default), but you can mount them individually if you only use one package.
| Export | What it covers |
|---|---|
commonShortcuts | Cross-package primitives: as-description, as-overlay, as-kbd, as-close-btn, as-dialog-close. |
formShortcuts | Form chrome: as-form*, as-field*, as-default-field, as-checkbox-radio*, as-array*, as-object*, as-collapsible*, as-decimal-number*, as-dropdown*, as-ref*, as-action*, as-no-data*, as-form-grid*. |
tableShortcuts | Table chrome: as-table*, as-cell-*, as-column-menu*, as-filter-dialog*, as-config-dialog*, as-filter-field*, as-fpill*, as-orderable-list*, as-preset-picker*, as-window-table*, as-window-skeleton*, as-row-actions*, as-sorter*, as-page*, as-confirm-dialog*. |
wfShortcuts | Workflow form chrome: as-wf-form*. |
aoothShortcuts | Aooth auth-flow field chrome (@atscript/vue-aooth): as-consent-array*, as-copy*, as-password-rules*, as-qr-code*, as-sso-provider*. |
Import them directly when you only need one:
import { defineConfig } from "unocss";
import { commonShortcuts, formShortcuts, asPresetVunor } from "@atscript/ui-styles";
import { mergeVunorShortcuts, vunorShortcuts } from "vunor/theme";
export default defineConfig({
presets: asPresetVunor(),
shortcuts: [vunorShortcuts(mergeVunorShortcuts([commonShortcuts, formShortcuts]))],
});Mostly you'll just use allShortcuts.
Extending the tree
mergeVunorShortcuts accepts an array of shortcut objects and folds them into a single merged map — later entries can extend earlier ones with hover / focus / nested selectors. The standard recipe:
import {
allShortcuts,
asPresetVunor,
defineShortcuts,
mergeVunorShortcuts,
} from "@atscript/ui-styles";
import { vunorShortcuts } from "vunor/theme";
const appShortcuts = defineShortcuts({
// New concept — sibling of the existing `as-*` tree
"as-foo-extra": "scope-primary border-1 layer-0 px-$m py-$s rounded-r2 text-body",
// Existing concept — paint over a state without forking the body
"as-form": {
"[&_.as-field-label]:": "uppercase tracking-wide",
},
});
export default defineConfig({
presets: asPresetVunor(),
shortcuts: [vunorShortcuts(mergeVunorShortcuts([allShortcuts, appShortcuts]))],
});Two patterns and when to reach for each:
- Add a sibling shortcut when introducing a new piece of UI. Pick a name that fits the
as-<case>-<concept>convention if it extends an existing concept, oras-<new-concept>if it stands alone. - Extend an existing shortcut's variant map when painting over a state (
hover:,focus-within:,[&_child]:,[&.error]:). Copying the whole body and tweaking it works, but it fragments the source of truth — future palette / dark-mode updates to the original won't reach your fork.
mergeVunorShortcuts is associative, so [allShortcuts, appShortcuts] and [appShortcuts, allShortcuts] produce different results. Order matters: put your overrides last so they win.
Overriding a built-in as-* shortcut
Extending adds a new class. Overriding repaints an existing one — change the form title's weight, drop the required-field asterisk, retint a section heading — without forking the whole shortcut body. This is the most common customization once the brand palette is set.
Wiring
Same merged-shortcuts recipe as extending. The override object goes last in the mergeVunorShortcuts array so it wins:
import {
allShortcuts,
asPresetVunor,
defineShortcuts,
mergeVunorShortcuts,
} from "@atscript/ui-styles";
import { vunorShortcuts } from "vunor/theme";
import { defineConfig } from "unocss";
const shortcutOverrides = defineShortcuts({
// Append a weight onto vunor's baked `btn`.
btn: { "": "fw-400" },
// `as-collapsible-title` bakes `text-body-l font-600`; remap to your h3 scale.
// `!` is needed because UnoCSS emits `font-<n>` in numeric order, so a plain
// `font-100` would lose to the baked `font-600`.
"as-collapsible-title": { "": "text-h3 !font-100" },
// Form root title bakes `text-[1.54em] font-700`; map to your h2, force weight 100.
"as-form-title": { "": "text-h2 !font-100" },
// Remove the required-field red asterisk. `as-default-field` renders it via
// `[&.required .as-field-label]::after { content: " *" }`; blank the content on
// that exact variant. `!` beats the baked content declaration (merge = append).
"as-default-field": { "[&.required_.as-field-label]:after:": "!content-['']" },
});
export default defineConfig({
presets: asPresetVunor(),
shortcuts: [vunorShortcuts(mergeVunorShortcuts([allShortcuts, shortcutOverrides]))],
});The import paths are the same as everywhere else on this page: allShortcuts, asPresetVunor, defineShortcuts, and mergeVunorShortcuts come from @atscript/ui-styles; vunorShortcuts comes from vunor/theme (ui-styles re-exports the other three vunor helpers but not vunorShortcuts). defineShortcuts is an identity/typing helper — it returns its input unchanged and only gives your editor the TVunorShortcut type.
The key mechanic — merge is APPEND, not replace
When allShortcuts (the base) and your override both define the same as-* name, mergeVunorShortcuts does not swap the base body for yours. It flattens each shortcut to a utility string and concatenates them — base + " " + override — per variant key. Two consequences you must internalize:
- You only write the one variant key you want to change. The shortcut's other keys stay in the base string untouched — no need to copy the whole body. In the example above,
as-form-titlekeeps its bakedtracking-[-0.02em]; you only restatedtext-*/font-*. - Both the baked utility AND your override land on the same element, so your override must win the CSS cascade. Specificity is equal (both are plain utilities), so reach for
!(important) on the property you're replacing.
Font-weight gotcha
UnoCSS emits font-<n> rules in numeric order, not source order. A plain font-100 appended after a baked font-600 still loses — font-600 sorts later in the stylesheet. Force it with !font-100.
Blanking a pseudo-element
To clear ::before / ::after content (e.g. the required-field asterisk), set the content to an empty string literal — content-[''] — not an empty value string "". An empty value produces a selector with no utility attached, which UnoCSS silently drops (no-op), leaving the baked content in place. Pair it with ! so it beats the base content-["_*"] declaration.
When to override a shortcut vs. tune the theme vs. swap a component
Three levers, smallest first:
| Goal | Lever | Where |
|---|---|---|
| Brand colors, radius, spacing, fingertip ladder | asPresetVunor({ palette, baseRadius, fingertip }) | Theme & Palette |
Restyle a specific as-* hook (weight, asterisk, one variant) | Shortcut override (this section) | here |
| Replace a field renderer entirely (custom widget) | :types / :components prop map | Forms — Customization |
Theme tuning is the broadest brush — it repaints everything at once and needs no shortcut knowledge. Override a shortcut when the palette is right but one visual rule isn't. Swap a component when no amount of CSS gets you there and you need different markup.
DOs and DON'Ts
- DO put
!(important) on the property you're replacing — merge appends, so a plain utility ties on specificity and may lose. DON'T expectfont-100to beat a bakedfont-600. - DO blank pseudo-content with
content-['']. DON'T use""(an empty value string) — it compiles to nothing and the baked content survives. - DO override just the one variant key you're changing. DON'T re-declare the whole shortcut body — you'd fork it and stop inheriting future palette / dark-mode fixes to the base.
Overridable label / field hooks
The handful you'll reach for most when restyling form chrome:
| Shortcut | Bakes | Override to… |
|---|---|---|
as-form-title | text-[1.54em] font-700 | remap the form header to your type scale |
as-collapsible-title | text-body-l font-600 | retint section headings |
as-default-field | layout + label/input/error variants | repaint the required asterisk, label, or any input |
as-field-label | (via as-default-field [&_label]) | label weight / casing |
as-field-description | as-description + offset | description text styling |
as-field-label, as-field-description, as-error-slot, and as-field-header-row are reachable the same way. For the complete catalog of every as-* shortcut name, see the @atscript/ui-styles API reference and the source shortcut tree.
Composing with vunor primitives
When you add a new as-* shortcut and want it to inherit palette, dark mode, and scope tinting, compose its body from vunor primitives instead of pixel literals or hex colors. The library's own shortcut tree follows the same approach — that's what lets a single palette.colors.primary change in presetVunor() repaint everything downstream. If you'd rather hardcode values in a particular shortcut, that's fine too; just know that shortcut won't follow palette changes.
| Intent | vunor primitive | Hardcoded equivalent |
|---|---|---|
| Gap / padding / margin | spacing tokens $xxs..$xxl | gap-[8px], px-[12px] |
| Control heights / touch targets | h-fingertip-xs/s/m/l/xl | h-[32px] |
| Body / secondary / title text | text-body, text-callout, text-body-l | text-[length:13px] |
| Icon-glyph sizing | em-based text-[1em], text-[1.25em] | w-[16px] h-[16px] |
| Elevated surfaces | shadow-popup, popup-card | custom box-shadow |
| Borders (default) | border-1 alone — color comes from active surface/layer | border-grey-200 dark:border-grey-800 |
| Focus rings | current-outline-hl outline i8-apply-outline | [box-shadow:0_0_0_3px_...] |
| Button / clickable surfaces | c8-filled / c8-flat / c8-outlined / c8-light / c8-chrome | custom hover/active rules |
| Inputs (bordered) | border-1 layer-0 current-outline-hl + i8-apply-outline on focus | i8-input i8-apply-border (leaks scope color as fill) |
The benefit of routing through these primitives is that a single palette.colors.primary change in presetVunor() repaints everything downstream. Pick whichever side of the table fits the level of theme-awareness you want for that particular shortcut.
Example shortcut bodies
A few real entries from the source so the patterns are concrete.
Form error banner
// shortcuts/form/as-form.ts
"as-form-error":
"scope-error surface-50 border-1 rounded-r2 px-$m py-$s mb-$s text-callout text-current-hl flex items-center gap-$s",scope-erroractivates the error palette branch — everycurrent-*/surface-*/border-*resolves through error scope.surface-50paints the banner background in a tinted scope variant.border-1alone — vunor pulls the border color from the active surface.text-current-hluses the highlighted variant of the active text color (error red).
Table number cell
// shortcuts/table/as-cell.ts
"as-cell-decimal": "text-right tabular-nums font-mono",A minimal shortcut. Right-alignment plus tabular-numerals so numbers in a column align by decimal. No color, no spacing — those come from the row's parent as-window-table chrome.
Close button with state map
// shortcuts/common/index.ts
"as-close-btn": {
"":
"inline-grid place-items-center size-fingertip-s p-0 border-0 " +
"bg-transparent text-current/80 cursor-pointer leading-none rounded-base " +
"flex-shrink-0 transition-colors duration-120 text-[1.25em]",
"hover:": "layer-2 text-current",
},
"as-dialog-close": "as-close-btn ml-auto",The object-form syntax lets a shortcut declare its base body under "" and then add variant bodies ("hover:", "focus-visible:", "[&_.icon]:"). Consumers paint over a single state without forking the body.
Progress button — c8-progress
// shortcuts/common/c8-progress.ts
"c8-progress": "relative overflow-hidden",
"c8-progress-fill":
"absolute inset-y-0 left-0 w-0 bg-black/20 animate-[progress-fill_var(--progress-duration,4s)_linear_forwards]",
"c8-progress-label": "relative",c8-progress turns any c8-* clickable surface into a self-filling progress button — the kind of "I'll fire when the bar reaches the end, but click me to fire now" control used by auto-redirect skip buttons, hold-to-confirm CTAs, and timed confirmations. The 3-class API:
<button
class="c8-filled scope-primary c8-progress h-fingertip-m px-$m"
:style="{ '--progress-duration': '4000ms' }"
>
<span class="c8-progress-fill" />
<span class="c8-progress-label">Confirm</span>
</button>c8-progresscomposes on top of anyc8-*base (c8-filled,c8-flat,c8-light, …). It addsrelative overflow-hiddenso the fill clips to the button's rounded corners.c8-progress-fillis absolutely positioned out of flow and animateswidth 0% → 100%over--progress-duration(set as an inline style on the host).bg-black/20is a uniform darken overlay that works on any underlying surface in both light and dark themes.c8-progress-labelis in-flowrelative— it sits above the absolute fill via source order. Required (not optional): without an in-flow child the button collapses to its padding box.
The animation is driven by @keyframes progress-fill, registered once as a UnoCSS preflight by asPresetVunor. Consumers don't have to register anything beyond installing the preset.
Default form field, deep selectors
// shortcuts/form/as-field.ts
"as-default-field": {
"": "as-grid-item flex flex-col gap-$xs relative",
"[&_label]:": "font-600",
"[&.required_.as-field-label]:after:":
'content-["_*"] scope-error text-current-hl font-700 ml-[0.1em]',
"[&_:is(input:not([type=checkbox]):not([type=radio]),select,textarea)]:": inputBase,
"[&_:is(input:not([type=checkbox]):not([type=radio]),select,textarea):focus]:":
"current-border-hl outline i8-apply-outline",
"[&.error_:is(input:not([type=checkbox]):not([type=radio]),select,textarea)]:":
"scope-error current-border-hl border-current",
},This is what makes form fields styleable through one shortcut. The base body lays out the field; the variant map styles every input element inside it (:is(input, select, textarea)) with consistent focus and error states. To restyle every input in your forms uniformly, override the relevant variant key in your merged shortcuts — that's typically less work than passing :class per-field.
Comma-separated arbitrary variants
Comma-separated selector lists silently break the dark: qualifier — UnoCSS only prefixes .dark onto the first selector. Wrap the inner list in :is(...) so the variant resolves to a single selector and theme-aware rules apply uniformly.
Same pitfall with nested attribute selectors: [&_tr[data-state=checked]]: silently fails. Wrap the inner attribute selector in :is(...):
"[&_tbody_tr:is([data-state=checked])]:": "scope-primary bg-current-hl/15",Class extractor & safelisting
Two pieces handle "which as-* classes ship in the consumer's CSS":
componentClasses— pre-computedRecord<kebab-name, string[]>mapping each component (e.g.as-form,as-filter-dialog) to every class its template emits. Generated at our build time from the source.helperAliases— pre-computedRecord<helperName, kebab-name[]>for the default-bundle helpers (createDefaultTypes,createDefaultControls,createDefaultCellTypes). A call tocreateDefaultControls()in your code pulls in classes for every default it wires up.
The custom extractor that asPresetVunor() registers walks your source for three signals:
- Subpath imports —
from "@atscript/vue-form/as-input". - Barrel imports —
import { AsInput } from "@atscript/vue-form". - Tags —
<AsInput>,<as-input>. - Helper calls —
createDefaultControls(),createDefaultTypes().
For each match it adds the corresponding kebab name to the safelist and pulls in componentClasses[name]. You don't have to safelist anything manually unless you reference a class via a string that the extractor can't see (e.g. @ui.form.prefix.icon: 'i-as-star' in a .as file).
For full manual control:
import { getComponentClasses, getHelperClasses, asPresetVunor } from "@atscript/ui-styles";
export default defineConfig({
presets: asPresetVunor({}),
safelist: [
...getComponentClasses(["as-form", "as-input", "as-select"]),
...getHelperClasses("createDefaultTypes"),
],
});getComponentClasses(kebabNames, exclude?) returns a deduped class array for the named components (companions included; an optional ReadonlySet of names to exclude prunes them and everything reachable only through them). getHelperClasses(...helperNames) does the same for the helper aliases.
Excluding defaults you swapped out
If you replaced one of our default components with your own, the default's classes still ship by default — the extractor sees createDefaultControls( in your source and assumes you want the whole bundle. Drop them with excludeComponents:
asPresetVunor({
excludeComponents: ["as-filter-dialog", "as-config-dialog"],
});After this, your custom MyFilterDialog renders normally, but the unused as-filter-dialog-* styles never make it into the bundle.
Next steps
- Pre-built CSS — opt out of UnoCSS entirely (loses dynamic theming).
- Theme & Palette — change colors and radius so the tree restyles itself.