Authoring @pantoken/components
@pantoken/components is the semantic RSCSS CSS API — a parameterized generator, not hand-written CSS. Each documented record is one file; the bucket index.ts is a barrel + registry, not a monolith.
Source layout
src/components/<name>/index.ts+<name>.css(COMPONENTSregistry).src/components/<name>/members/<member>/index.ts+<member>.css— a promoted sub-component (see "Sub-components (@memberOf)" below).src/utilities/<name>/index.ts+<name>.css(UTILITIES).src/rules/{base,prose}/index.ts+<name>.css(RULES).src/declarations/<name>/index.ts+<name>.css(DECLARATIONS).src/lib/:helpers.ts(ns/scope/glyph consts/spacing scales/ComponentOptions/DEFAULT_PREFIX),define.ts(defineComponent/defineUtility/defineRule/defineDeclaration),aliases.ts(withSizeAliases/withAliases),css.ts(the identitycsstag),headings.ts,field-controls.ts.
Sub-components (@memberOf)
When a component maps to a real, separately-named instui sub-component export (Tabs.Panel, Table.Row, Menu.Item, …), promote it to its own record instead of documenting it as a @part bullet on the parent:
- New files:
src/components/<parent>/members/<member>/<member>.css+index.ts, same shape as any other record (defineComponent, registered inCOMPONENTS, exported fromsrc/index.ts). - The record's cssdoc name is dotted:
@component <parent>.<member>(e.g.@component menu.item) — this keeps member names globally unique across the whole registry even when two different parents have a same-named part (menu.itemvsside-nav-bar.item), which a bareitem/itemcollision would break for theproviders-indexed cross-file@memberOf/@structureresolution. The TS identifier stays camelCase (menuItem/menuItemCss), since a dot isn't legal there anyway. - Add
@memberOf <parent>on the member's own record. - Always author an explicit
@class .<member>(or@selector) rather than relying on inference — a member's CSS typically keeps the parent's@scope (.pfx-<parent>) { … }wrapper (real cascade scoping, not just documentation), and inference across that boundary is unreliable; the fallback className for a dotted name (.menu.item) would also be misleading. - Physically move the CSS into the member's own file — don't leave a doc-only record pointing at rules that still live in the parent. The aggregator just concatenates raw CSS text regardless of which file it came from, so this is safe even for native-element selectors with no
@scope(e.g.table.row'str) or where a shared declaration has to be duplicated across a couple of sibling members (e.g.table.-layout-stacked's box-reset, split per part rather than kept as one five-selector compound rule) — cssdoc requires each record's own file to own its own selectors. - Remove the promoted
@partline — a@memberOfrecord documents its own structure on its own page, and@memberOfalone (independent of nesting in the parent's own@structure) already feeds the parent's generated "Subcomponents" section. - Give the member's own
@structurean@component <parent> { … }node instead of a bare@scope (.pfx-<parent>) { … }wrapper — this embeds the member's real ancestor path (down to the member's own selector, with any of its own nested parts) as a cross-linked record reference, e.g.@component table { tbody { tr { td {} } } }fortable.cell. See thetable.*members for the full pattern, including a sibling-alternation node liketable.body'stbody. - If a parent-level modifier restyles a promoted member (e.g.
tabs's-variant-secondaryrestylingtabs.tab, ortable's-layout-stackedrestyling all six of its members), mark it inline:@modifier -variant-secondary — … @affects tabs.tab — …. Multiple@affects <parent>.<member>markers can follow one another on the same@modifierline. - Register the member in
COMPONENTSright after its parent, export<member>Cssfromsrc/index.tsnext to the parent's export, and add it totests/public-surface.test.ts'sEXPECTEDlist (that array is sorted before comparison, so insertion position doesn't matter). Fix any existing test that asserted on the parent'sxxxCssfor content that moved to the member. - Don't promote a
@partjust because it's stylistically distinct — the bar is a real, separately exported instui component (List.Itemyes; a passive style hook like byline's.title/.descriptionor tabs'.list, which has no correspondingTabs.Listexport, no).
Alternate DOM shapes (@variant)
@structure supports @variant <name>? { … } blocks for a component that genuinely renders one of several alternative DOM shapes (e.g. a <label> wrapping a control vs. a <label for> plus a sibling control). Only add one when a second shape is already real and CSS-supported — check the record's existing @examples and rules first; don't invent a shape the component doesn't actually render. As of this pass, no component in this package authors a genuine second DOM shape (things like a checkbox's -label-placement-* only reorder the same markup via CSS, which isn't a @variant), so none carry one.
Authoring a record
export const foo = defineComponent({
name,
summary,
modifiers,
parts,
cssProperties,
examples,
structure,
demo,
css: (p) =>
// prettier-ignore
css`…css body…`,
});
export const fooCss = foo.css;- The doc comment is metadata — plain strings, no backtick/
${escaping. - Only the
cssbody stays a template literal. Tag it withcssand put a// prettier-ignoreline right before it (before the template for a direct arrow body, beforereturnfor a block). oxfmt treatscssandstyledtagged templates as embedded CSS and corrupts interpolated selectors (${p}foo.-mod→${p}foo .-mod, a different meaning) otherwise. SettingembeddedLanguageFormattingto off does not stop it. See the engineering log. - Add the record to its bucket
index.ts. ForCOMPONENTSthe order is load-bearing — it matches thecomponentsCss()concat order, not alphabetical.
The modifier convention (key-value RSCSS compound)
- Modifiers are
.${p}<comp>.-<prop>-<val>, aligned to InstUI prop names (-color-secondary,-size-sm,-shape-circle,-variant-title-page). - Booleans are the prop name alone, presence = true (
-clickable,-condensed). A default-on boolean toggled off inverts:-without-background,-without-border. - Sizes use one scale
-size-{xs,sm,md,lg,xl}, emitted with both short and long spellings as first-class aliases viawithSizeAliases(). - Nested elements are unprefixed short classes scoped as descendants (
.instui-menu .item), not BEM__. Non-nested structural parts that can't be descendant-scoped are flat prefixed (.instui-badge-wrapper). A guard test rejects__and--. - Avoid hyphen-then-digit tokens (
-2xsneeds escaping); keep tokens letter-leading.
Reuse an existing global utility — don't invent a parallel one
view's and text's own key-value modifiers (background, border, shadow, display, position, overflow, cursor, colour, weight, size, transform, variants) are copied into global utilities — usable bare (<div class="--bg-secondary">) or chained onto any component, core or plugin-authored (<button class="instui-button --bg-danger">) — without touching the component's own chained modifiers, which stay exactly as authored.
- Never invent a new class word for something a global utility already covers. If a generic
bg/text/border/border-radius/border-width/box-shadow/displayutility already exists, extend it (add a name, or an explicit[name, token]pair for a value outside its usual token family) — don't author a parallel word like-stroke-*or a word-spelled duplicate like-border-radius-smallnext to the existing-border-radius-sm. - Only a genuinely new concept (no existing global analogue) gets its own utility file — see
utilities/position/,utilities/overflow/,utilities/cursor/. - A global utility modifier is spelled with a double dash (
--bg-secondary), never a single dash (single-dash-modis reserved for a component's own modifiers) — this is a deliberate, non-colliding namespace split, not a stylistic choice. - The selector mechanism is
globalModifierSelector(p, name)(@pantoken/utils)::where(*).--name.--name.--name. A plain repeated class selector already matches any element carrying it — standalone, or chained onto a component, core or plugin-authored (.instui-card,.instui-agent-shell) — no per-component enumeration needed; the:where(*)wrapper contributes zero specificity of its own (documentation that this is a global modifier, not a scoping condition), while the modifier class repeated 3x gives the rule (0,3,0) specificity, which deterministically outranks any real 2-class component-modifier compound (.instui-view.-mod, 0,2,0) regardless of source/import order.packages/utils'scolorUtilitiesCss/tokenUtilitiesCssbuild on the same helper. - This replaced an older per-component compound-selector fan-out (
GLOBAL_ALIAS_TARGETS/globalSelectors()/chainTargets, one.instui-<component>.-modselector per real core component) that: couldn't reach plugin-authored components at all (the core package can't know a plugin's class names at its own build time); relied oncssdoc.jsonc'sglobalPrecedence: "base"and import order for override behavior, which only ever affected generated documentation merge order, not real browser cascade; and didn't scale to a utility with a large modifier surface (see spacing below).
Universal spacing/gap modifiers
margin/padding/gaputility classes (utilities/spacing/,utilities/gap/) are universal: every registered component (core or plugin) gets the modifier for free viaglobalModifierSelector, with no per-record authoring — see the mechanism above.- Each value ships in exactly two spellings: short (
--mb-sm) and fully long, word-spelled (--margin-bottom-small) — never a mixed-segment form (no-margin-e-small, no-me-small). - Because these modifiers are documented on the separate
spacing/gaputility records, not on the component they're chained onto, resolving.instui-card.--mb-smrequires cssdoc to fall back from the base record to any record tagged@global— every utility record here carries that tag for exactly this reason.@cssdoc/eslint-plugin'svalid-class-usage(and the sharedisModifier/deprecationOflookups behind it) already do this fallback automatically; nocssdoc.jsoncoption is involved. The one real prerequisite is that whatever CSS is passed tovalid-class-usage'scssoption still carries the doc comments (an unminified build) — the publisheddist/*.cssbundles are minified and strip all comments, so pointing consumer-side lint at them would flag everything, not just globals. - If a component already sets its own
margin/padding/gapfrom a component-specific token (e.g. card's responsive padding, breadcrumb'sgap), document that in@remarksand warn that chaining a spacing/gap utility modifier overrides it — see breadcrumb, button, byline, checkbox, form-field, form-field-group, form-field-messages, link, list, metric, pagination, radio, radio-input-group, rating, side-nav-bar, and tag for the pattern.
Deprecated and alias modifiers (auto-discovered, always functional)
- Author a modifier's metadata with
alias: "{@link -canonical}"for a pure rename (no behavior change, e.g.-toggle→-variant-toggle), ordeprecated: "{@link -canonical}"for a true deprecation (behavior changed, e.g. the color/spacing normalization renames like avatar's-color-accent*or alert's-variant-*).withAliasesreads either tag and clones each canonical rule under the alias name — there is no hardcoded pairs list, and both tags render a functional twin. - A deprecated or aliased modifier must be a functional alias — never a doc-only noop (user's absolute rule).
- The docs render
@aliasas a blue "Alias" pill (informational rename) and@deprecatedas a red "Deprecated" pill (a real behavior change) — seedocs/scripts/api-badge-classes.ts. - The alias post-processors run on the CSS body only, before the doc block is prepended, because the doc block's
{@link}braces confuse the brace-based scanners. They anchor on the base class (.instui-radio.-variant-toggle), not the bare token, so a bare.-canonicalmatch doesn't wrongly clone:not(.-canonical)or compound rules.
@scope, nesting, and root targeting
The default authoring system is the alert.css pattern:
- Use a single scoped root block:
@scope (.instui-foo) { :scope { … } }. - Keep root-owned behavior nested under that root with
&(&::before,&.-variant-x,&[class*="-icon-"],&:has(> .part)). - Keep child-only selectors as structural descendants inside the root (
> .part,.part,.part::before) instead of restating independent top-level:scope > ...rules. - Never use functional
:scope(...); use standard combinators (:scope > .part) or nested child selectors inside:scope.
This keeps ownership explicit (root vs descendant), reduces duplicated selector prefixes, and produces more stable diffs when modifier/state branches are added.
When you must support legacy generator behavior (scope(root, body, children?) in helpers.ts) during migration, preserve semantic parity first, then normalize toward the scoped-root nested form above.
@scope/:is/:has/:where are not namespacing tools — only a unique or prefixed name prevents a collision. Scoping narrows intent and deep-nesting collisions; it isn't collision-proof.
Local utility @property values
For repeated record-local dimensions or timings (for example rail/glyph sizes), define short local custom properties and register them with @property near the top of the file:
- Use local names without global namespace prefixes when the property is record-scoped (
--rail-size,--glyph-size,--duration). - Keep global prefixes (
--instui-*,--pantoken-*) only for truly shared cross-record/token surfaces. - Document internal-only utility properties with valid
@csspropertyprose and@readonlymarkers when they are not public customization API.
Icons
Glyphs render via a single generic [class*="${p}icon-"]::before mask painter (currentColor, 1em box). A glyph class rides a host directly because --pantoken-glyph is a plain (non-@property) custom prop that inherits into ::before. So <button class="instui-button -shape-square -icon-arrow-right"> needs no nested icon span. Built-in component glyphs source from the shared --instui-icon-<name> tokens via an iconMask(name) helper.
Per-component checklist
Extend the record, update the drift-checked test, update the guide (docs/guide/components.md), and add or update a demo. Then run vp check --fix, the component + demo tests, check:publish, and lint:markdown. Browser-verify anything visual. Skip upstream tokens that are @property-registered but value-less (being removed upstream) — don't invent them.