Notes for agents working on @volverjs/style, the SCSS design system that produces the CSS
consumed by @volverjs/ui-vue and by static pages.
The source of truth is SCSS under src/, and the deliverable is the compiled CSS under
dist/. Nothing in dist/ is edited by hand.
| Path | What lives there |
|---|---|
src/settings/components/ |
One configuration map per component ($vv-button, $vv-input-text, ...). This is where a component's look is declared. |
src/tools/mixin-modules/_bem.scss |
The BEM generator: it walks those maps and emits blocks, elements, modifiers, states, breakpoints and transitions. |
src/tools/mixin-modules/_theme.scss |
The same walk for the dark theme, without @extend. |
src/components/ |
One entry per component, which calls the generator with its map. |
src/props/, src/settings/ |
Design tokens and the global settings the maps read. |
src/utilities/, src/base.scss, src/_preflight.scss |
Utility classes, base layer and reset. |
docs/ |
Source of the documentation site. npm run styleguide builds it with Vite SSG into styleguide/, which is generated and git ignored. |
A component map is a nested structure of element, modifier, state, pseudo,
breakpoint and transition keys. Two keys are not CSS and drive the generator instead:
_alias gives an element a second selector for plain markup (_alias: '> small' next to
.vv-select__hint), and _combinator scopes it. An _alias must be a single selector, never
a comma separated list.
Run all of these before reporting a change as finished, and report a skipped one as skipped.
npm run stylelint # stylelint over src/**/*.scss
npm run lint # eslint over the repository
npm run build # compiles dist/ and regenerates design-tokens.json
npm run stylelint:dist # stylelint over the compiled dist/**/*.css
npm run check:colors # contrast tokens and tint derived neutrals, see scripts/checkColors.jsApart from check:colors, there are no unit tests. Correctness is established by compiling and
reading the emitted CSS, so the two checks below carry the weight tests would carry elsewhere.
Snapshot dist/ before touching anything, rebuild, and diff:
cp -R dist /tmp/dist-before
npm run build
diff -r /tmp/dist-before distSay in the changelog which of the two outcomes you got and why:
- Identical byte for byte. Expected for a refactor, and for a fix to a branch that no component map reaches yet. This is the strongest evidence a change is safe.
- Changed. Then every changed rule is intentional and accounted for. Read the diff rule by rule, do not skim it.
npm run build also rewrites design-tokens.json and the exports map in package.json.
Both are tracked, and both should come back unchanged unless tokens or entry points actually
moved.
Much of the generator is only exercised by shapes no component map uses today. Do not conclude
such a branch is fine because dist/ did not move: compile a synthetic map that walks straight
into it, and read the selectors it emits.
cat > /tmp/probe.scss <<'EOF'
@use 'src/context' as *;
$m: (/* the shape under test */);
@include spread-map-into-bem($map: $m, $block: 'vv-probe', $bps: $breakpoints);
EOF
node -e "
const sass = require('sass-embedded');
console.log(sass.compile('/tmp/probe.scss', { loadPaths: ['.'] }).css);
"Pass $bps: $breakpoints, otherwise the breakpoint branches compile to nothing and the probe
silently proves less than it looks. Run the probe with and without the change, and diff the two
outputs: that is what tells you which rules moved.
- A selector list is not a selector. The generator passes selectors around as strings glued
with commas. Suffixing or compounding one of those as a whole reaches its last entry alone
and leaves the ones before it bare. Walk the list, or send it through
list-to-string, which is how the rest of the generator emits one. list.appendon a single value gives a space separated list. A list of one has no separator, so Sass falls back to a space, and the result interpolates to a descendant selector instead of two alternatives.@for $i from 1 through 0counts down. It runs the body with an index that reaches past an empty list rather than skipping it.- The empty string is truthy in Sass.
@if $modifieris true for the''default, so test$modifier != ''when the difference matters. - Assignment reaches the enclosing scope. Reusing a variable name inside a nested loop or a recursive mixin writes to the outer one. The generator relies on this on purpose in places and has been broken by it in others.
- A
var()inside a custom property is substituted where that property is declared, not where it is read. A declaration that has to follow a token written further down the tree (--input-range-progress,--direction) is emitted as a plain declaration, wrapped in[brackets]in the map, instead of going through the generated--vv-*property. - A relative unit inside a custom property is resolved where the property is read, which is
the opposite of the rule above.
1lhand1emtravel as a token stream and compute against the element that uses them, even when the property is declared at the root: measured in Chrome 152 and Firefox 155,--x: 1lhdeclared at:rootand read on an element whose line box is 64px gives 64px, not the root's 24px. That is what lets--accordion-marker-sizestay a token on the block and still follow the line box of the summary, which is the element that consumes it. So a declaration whose value carries only relative units needs no[brackets], while one that reads a token another element declares still does. - The channel keywords of relative color syntax do not have the same type everywhere.
Safari 17 reads
r g bofrgb()andcolor(srgb),s lofhsl()and thealphakeyword as percentages, Chrome and Firefox as numbers, socalc(l + 12)is valid in the latter and drops the whole value in Safari 17, andcalc(l + 12%)the other way round. A product with a plain number (calc(l * 1.1),calc(x * alpha)) is valid in both, a sum is not; the channels ofxyz-d65andoklchare numbers everywhere. Measured in Chrome 154, Firefox 155 and Safari 17.6. The fixed steps of word and surface are emitted twice for this, the percent form under@supports not (color: hsl(from red h s calc(l + 1))). A result written outside sRGB is also not exact in Chrome:oklch(1 0 0)and the D65 white point inxyz-d65resolve to0.99987 1.00005 1.00007, and text drawn with that differs from white at its edges, while a round trip of an sRGB color throughxyz-d65lands within a fraction of an 8-bit step. The contrast and readable tokens in_functions.scssroute around both. The HSL channel branch,$use-color-mix: false, is the one for browsers without relative color syntax: it emits none, a readable role there is its shade, the contrast texts and the covers are chosen at compile time, andcheck:colorsfails on any that slips through. - An element has no modifiers. A
modifierkey inside anelementmap is not emitted as.block__element--modifier: its declarations are flattened into the rule of the element itself, so they apply to every instance and win over the declarations before them. Express a variant of an element as a state read from the DOM, as a modifier of the block that reaches the element, or as a second element. .preflightdresses bare tags at (0,1,0), above the zero specificity of every component rule. An element carrying a class of a component ([class*='vv-']) is excluded from all of its rules, sobutton.vv-badge__buttonorlabel.vv-radio--segmentare left to their component. A child that a component reaches only through an_aliashas no class and is still dressed: a bare<button>inside an action badge comes out as a primary button, 77.1px wide instead of 46.7px. Give such a child its element class, or wrap the block in.preflight-revert. A new component that owns bare children should document that.
- Text written in a color takes a readable role, never a shade.
--color-brand-readableon the neutral surfaces down to--color-surface-2,--color-brand-readable-stronga step further from them, and--color-surface-brand-readableon the tinted surface of the color. The dark theme redeclares the roles, so a light rule needs no dark override for its text color, while a shade written as text reads only by chance. A state that changes the background changes the role with it: the hoveredvv-dropdown-actionsits on--color-surface-brandand takes--color-surface-brand-readable. Text on a surface deeper than--color-surface-2needs$color-readable-depthraised, not a darker shade. A color that has to be seen but not read, the bar ofvv-progress, what shows the focus (a ring, the caret, the bar under a field), a bar or a border that shows a state, takes--color-brand-graphic, which keeps 3:1 on the same surfaces; a neutral ring takes--color-gray.check:colorsfails on avar(--color-*)that nothing declares, and on a focus indicator in a palette color outside its graphic role, followed through the custom properties it reads, or incurrentcoloror a light gray. - The color tokens an application reads at runtime are a contract. The contrast, cover,
readable and graphic tokens, their
text-utilities, the selector, the at-rules and the layer they are declared in, and the order of the layers are read by name from applications that set their brand at runtime, and region 6 ofcheck:colorsfails on a rename. Changing one is a breaking change: say so in the changelog and update the check in the same commit. - The first line offset is spelled out, not factored out. Five declarations centre an icon
on the first line of a label that wraps, in
$vv-alert,$vv-nav,$vv-checkbox,$vv-radioand theself-first-lineutility, each ascalc((1lh - <the size>) / 2). The component maps are data and none of them calls a Sass function, so a helper would mean importingtoolsinto the settings files in order to hide a formula from the map that prints it. If the formula has to change it changes in those five places, andself-first-lineinsrc/utilities/flexbox.scssis the one to read first. - Write everything that lands in the repository in English: commits, comments, changelog.
- Prose carries no em dashes or en dashes.
- Every fix gets a
CHANGELOG.mdentry under the release it ships in, written as prose that explains the mechanism and the consequence, not as a one line summary. Match the surrounding entries. versioninpackage.jsonstays0.0.0and is set at publish time. The changelog heading is what names a release.- Commit only when asked, and group the work into coherent commits rather than one per edit.