Architecture overview

A tour of the SCSS module graph, the plugin pattern, and the anti-circularity rules — at the level useful for adding components, not just consuming them.

The shape of src/

Every category lives in its own directory, and every component lives in a subdirectory under its category. The architecture is heavily inspired by Bulma: the per-component _variables.scss + _plugin.scss split, the .is-* modifier scheme, the named-combination colour system, and the per-category directory layout all trace back to Bulma's sass/ shape. Where Krysalicss diverges, the divergence is spelled out below; where it doesn't, treat Bulma's design rationale as the prior art.

Categories are: base/, layout/, typography/, module/, element/, helper/, theme/, and variables/. The first five host components; the last three are leaf modules that components consume.

The plugin pattern

Every component is two files plus an index:

  • _variables.scss: the configurable knobs. Selectors, sizes, colors, radii: all declared !default so consumers can override with @use ... with ().
  • _plugin.scss: the actual CSS rules, reading from _variables.scss. Wraps emission in a single @if variables.$feature-{category}-{name} guard.
  • _index.scss: forwards both. Allows @use 'category/component' to work without the consumer knowing the internal file shape.

This split is the single most important thing about the codebase. It is what makes selectors configurable, lets feature flags strip components cleanly, and gives consumers a stable surface to override.

Feature flags

Flags live in variables/_feature.scss. The shape is flat: one boolean per category-name identifier: driven by a single $list the consumer configures at the entry point (re-exported by the root entry as $feature-list).

Internally, _feature.scss exposes one boolean per known component ($feature-element-button, $feature-module-card, etc.), each computed from $list via a simple lookup. Plugins consume those booleans and emit nothing if their flag is false. The consumer never sees the booleans; they only see $feature-list.

Why a flat list rather than a map? Because the internal references are flat ($feature-element-button, never element.button), and asking consumers to write (element: (button)) when the framework reads element-button would be needless asymmetry.

The anti-circularity rules

SCSS modules can deadlock: file A imports B which imports A: and Sass will blow up with a confusing message. Three rules avoid the trap:

  1. variables/ is a leaf. Files there never @use a category. They are imported, never importers.
  2. Plugins import only from variables/ and helper/ (and their own _variables.scss). They do not import each other.
  3. Themes import only variables/ and theme/_create.scss. They do not consume components.

Followed strictly, the graph is a strict DAG: variables → helper → category plugins → root entry. Themes hang off the side, not in the middle.

The two entries

src/krysalicss.scss is batteries-included: it pulls every category, the default light theme, and the dark theme. It's what @use '@adnap/krysalicss' resolves to.

src/_index.scss is forwards-only: it exposes every category but does not emit any theme. Consumers who want their own theme use this entry and bring their own theme/<name>.scss file.

The split exists because most consumers want the bundled themes and a minority want neither. Defaulting to "ship both, consumers can opt out" is friendlier than defaulting to "ship neither, every consumer must re-export".

Theme tokens

Themes don't emit component CSS. They emit one block of --kc-* custom properties on :root (or a media query, or a class selector). Components reference those properties via the defaultProperty mixin in variables/_default.scss, which produces both a hardcoded fallback and a var() reference.

The arrangement is what makes theming separable from feature flags. You can compile the framework with a tiny feature subset and still apply any theme: the theme controls colors, the components control which selectors exist.

A note on the reset and <ul> semantics

The base/reset plugin strips list-style from every <ul> and <ol>, because the framework treats raw lists as navigation/grouping primitives, not prose. Bulleted prose lists belong inside .content, which restores disc/circle/square at nesting depths 1/2/3.

Why this matters for accessibility: Safari (and a few screen-reader engines) apply a heuristic that demotes an unstyled <ul> to plain text in the accessibility tree, dropping the "list, N items" announcement. Two remediations, pick whichever fits the markup:

  • Wrap prose in .content. Bullets come back; SR announcement is preserved. This is the default path for article-shaped content.
  • Add role="list" to bare <ul>s when bullets are deliberately suppressed (nav menus, tag lists, anywhere the list shape carries semantic weight without visual bullets). The attribute is redundant on a styled <ul> but defends against the Safari demotion when the reset has stripped list-style.

The framework does not auto-apply role="list" because it cannot distinguish "deliberately bullet-less prose" from "navigation list" from the SCSS layer. Consumers carry that decision.

Where to dig deeper