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!defaultso 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:
variables/is a leaf. Files there never@usea category. They are imported, never importers.- Plugins import only from
variables/andhelper/(and their own_variables.scss). They do not import each other. - Themes import only
variables/andtheme/_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 strippedlist-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
- Theme contract reference: the surface a theme must satisfy.