Building an FSCSS Module

Experience writeup from siri-wave.fscss and flux-wave.fscss — what still holds, updated for FSCSS 1.2.6.

Notes from turning two raw CSS components (a Siri-style blob loader and a flowing gradient wave) into reusable modules. The anatomy and silent-failure habits below are still the real lessons.

Annotated for 1.2.x: arrays in declarations, count(@use(...)) and count(@arr.x!.length), empty{/* preserve */}, stronger @import, and IDE libck / xfcmd. See also create-module · modules-guide · xfcmds.

Module anatomy that held up

Working modules in the ecosystem (st-core, these two, and later packs) share the same shape:

  1. Root tokens — @define X-root(root:root){...} writes every custom property the module needs on a selector (:root by default). Call once before anything else.
  2. Scoped base reset — @use(st), @use(st) *{...}, never a bare global *. A global reset inside a component module fights the next module on the same page. Shared helper: scoped-reset from fscss:micros.
  3. Structural mixins — one per visual piece (container, line, band…), each with a selector parameter so the same mixin can target different classes.
  4. Optional preset — X-preset(st:...) wires the common case; individual mixins stay available for custom composition.
Still true in 1.2.6. Tooling and array ergonomics improved; this anatomy did not need replacing.

@define blocks: what actually breaks

The syntax is forgiving. The mistakes that cost real time were structural.

Don’t wrap a child mixin call in a selector block it doesn’t need

If sk-line(st, w, h) already builds its full selector internally, wrapping the call doubles the selector:

FSCSS — wrong
@use(st) .sk-line-1{
  @sk-line(.sk-line-1, 100%)   /* wrong — doubles selector */
}

Compiles cleanly, matches nothing. Call the child with the full selector as the argument:

FSCSS — right
@sk-line(@use(st) .sk-line-1, 100%)

Stray leading punctuation in the root block

Watch for :@use(root){ vs the intended selector form. Scan every root mixin before ship.

Declared parameter never referenced = silent no-op

sk-list(st, count:5) with a body that hardcodes count(5) means @sk-list(.feed, 6) still renders five items. Every parameter should appear via @use(...) in the body.

1.2.6 @import: named imports can carry dependency @defines; as renames only in that file (original name undefined there). Duplicates / cycles become CSS comments, not infinite loops — @import guide.

Arrays and loops

@arr.name[] expands as a loop when the reference sits in selector position, followed by a block:

FSCSS
div:nth-child(@arr.colors[]){
  color: @arr.colors[];
}

1.2.x: arrays also resolve cleanly inside declarations — derived arrays, method results as new sources (!.reverse, !.list, !.length, !+[…], !-[index]). Use selector+block when you need N rules; use declaration-time arrays for tokens and parallel data.

When generated rules must not be eaten as a selector wrapper:

FSCSS
empty{ /* preserve */ }
/* loop / generated rules */

Index + values (parallel arrays)

FSCSS
@arr colors[magenta, cyan, green, purple, orange, blue]
@arr colors-i[count(@arr.colors!.length, 1)]

@use(st).band-@arr.colors-i[]{
  /* use @arr.colors[@arr.colors-i[]] etc. */
}

Same idea as st-core@v2 chart points. Shared helper: @mirror(data, idx) from fscss:micros.

count() with literals, @use, and array length

As of current FSCSS (1.2.x), count() accepts:

  • Literals — count(6), count(6, 1) (end, optional start)
  • Parameters — count(@use(n)), count(@use(n), 1) inside @define
  • Array length — count(@arr.data!.length), count(@arr.data!.length, 1)
FSCSS — parameter
@define bands(st:.wave, n:4){`
  @arr idx[count(@use(n), 1)]
  @use(st) .band-@arr.idx[]{
    /* n items, driven by @use(n) */
  }
`}

@bands(.hero-wave, 6)
FSCSS — length of data
@arr data[10, 20, 30, 40, 50]
@arr idx[count(@arr.data!.length, 1)]

/* or */
@import((mirror) from fscss:micros)
@mirror(data, idx)
Prefer data-driven count(@arr.x!.length) when the list already exists, and count(@use(n)) when the caller only passes a size. Both are supported; pick the API that matches the mixin.

Older builds were less reliable when count() saw unresolved parameter text — modules from that era used literal count(4) plus a swappable array name. On 1.2.6+ you can pass size through @use or derive it from !.length without that workaround.

Debugging technique that actually worked

exec(_log, "message") remains the reliable way to see expansions. FSCSS still prefers silent empty output over hard failure for some bad expansions — log around the suspect site and compare console vs compiled CSS.

FSCSS
exec(_log, "before loop")
@arr colors-i[count(@arr.colors!.length, 1)]
exec(_log, "@arr.colors-i")

In the browser IDE: compile, watch console + CSS pane. CLI: write output to a file and search for expected selectors.

Naming, to avoid collisions

  • Keyframe names — prefix (siri-pulse, flux-flow). Bare pulse collides as soon as a second animated module lands.
  • Custom properties — same namespace everywhere (--siri-*, not mixed --wave-* / --siri-wave-*).
  • Import names — bare module name or quoted URL/path. Related packs (siri-wave, flux-wave) stay separate module names; family ties live in docs and naming, not a variant path syntax.
  • package.json — name drives libck my-module; file.usage.directive should match prefixes, or use "" if helpers are mixed.

Publish shape & IDE enable

Beyond the .fscss source: package.json with file.modules, file.remote, helpers, and optional xfcmd.enable / disable so the IDE can run:

IDE terminal
libck my-module
yes

Details: modules guide · create-module · xfcmds guide.

Checklist for the next module

Before you open the PR

  • Root tokens mixin — one block, called once
  • Base reset scoped to the component (or scoped-reset from micros)
  • Structural mixins take a selector param; no doubled wrappers on child calls
  • Every declared parameter used via @use(...)
  • Loops: selector+block; empty{/* preserve */} when needed
  • count() via literal, @use(n), or @arr.x!.length as fits the API
  • Keyframes and CSS variables prefixed with the module namespace
  • package.json: extension, fscss_version, modules, remote, accurate helpers
  • Optional xfcmds/enable.xfcmd + disable.xfcmd for libck
  • Selective import + CLI/IDE compile; exec(_log, …) on doubtful expansions

Ship the next one

Anatomy, namespacing, and “does this parameter actually get @used?” still decide whether a module is pleasant to consume. Use count(@use(n)) or count(@arr.data!.length) freely on current compilers; reach for exec(_log, …) when the CSS pane is quiet but the page is wrong.