Skip to content

Design System Styling

This guide documents styling rules for Easy8 Design System components.

Scope

  • Applies to app/frontend/src/design_system/**/stylesheets/.
  • Covers SCSS, BEM classes, tokens, variants, sizes, states, and visual state selectors.
  • Does not cover legacy Easy styles, Vuetify overrides, Rails wrappers, or JavaScript business logic.

Styling Layer

  • Keep Design System styles next to the owning component.
  • Shared Design System styling primitives belong in app/frontend/src/design_system/shared/stylesheets/.
  • Do not move legacy UI into Design System styling unless the task is an intentional migration.
  • Do not patch legacy Easy styles when a Design System component owns the behavior.

File Structure

  • Main component stylesheet: stylesheets/_<component>.scss.
  • Local abstracts: stylesheets/_abstracts/.
  • Use local abstracts for variables, sizes, states, palettes, and schemes.
  • Do not put final component styles in stylesheets/temp/; temporary styles are ignored by Stylelint.

BEM And Classes

  • Root class: ds-<component>.
  • Element class: ds-<component>__<element>.
  • Modifier class: ds-<component>--<modifier>.
  • State class: is-*, for example is-disabled, is-error, is-selected.
  • Keep reusable class names in constants/classes.ts.
  • Keep constants/classes.ts declarative; do not put class-building logic there.

Example:

export const DS_BUTTON_CLASSES = {
  Root: "ds-button",
  VariantPrimary: "ds-button--primary",
  FullWidth: "ds-button--full-width",
  AlignLeft: "ds-button--left",
} as const;

SCSS Factory Mixins

  • Scope component styles with factory.component(...); it adds [data-design-system] namespacing automatically.
  • Use factory.variantsWithStates, factory.variants, factory.sizes, and factory.states.
  • Validate new variants, sizes, and states against shared/stylesheets/variables/_validators.scss.
  • Map variants, sizes, and states 1:1 from Figma or the component API.

Example:

@use 'sass:map';
@use '../../../shared/stylesheets/utils/mixins' as factory;
@use '_abstracts/button.variables' as v-button;

@include factory.component(v-button.$name) {
  box-sizing: border-box;
  display: inline-flex;
  align-items: center;

  @include factory.variantsWithStates(v-button.$variants, v-button.$name) using($name, $props) {
    color: map.get($props, 'text');
    background-color: map.get($props, 'background');
  }

  @include factory.sizes(v-button.$sizes) using($name, $props) {
    height: map.get($props, 'height');
    padding: map.get($props, 'padding');
  }
}

BEM elements and modifiers stay inside the scoped component block:

@include factory.component(v-input.$name) {
  &__input-wrapper {
    display: flex;
    align-items: center;
  }

  &__input {
    flex: 1;
    min-width: 0;
    background: transparent;
    border: none;
  }

  &--textarea {
    align-items: stretch;

    .#{v-input.$name}__input-wrapper {
      align-items: stretch;
    }
  }
}

Variables, Schemes, And Palettes

  • Keep component token aliases in _abstracts/_<component>.variables.scss.
  • Store $name, $sizes, $variants, $states, and token aliases in variables files.
  • Use maps for values consumed by factory mixins.
  • Use nested states inside a variant map when hover, focus, selected, or disabled values depend on the variant.
  • Use a local *.scheme.scss or *.palette.scss mixin when a repeated visual color set cannot be expressed cleanly through factory maps.
  • Keep scheme and palette mixins small; they should apply visual declarations, not layout or behavior.
  • Scheme and palette values must still come from Design System tokens or component variables.

Example:

@use 'sass:map';
@use 'button.variables' as v-button;

@mixin set($palette) {
  &.#{v-button.$name} {
    color: map.get($palette, 'text');
    background-color: map.get($palette, 'background');
    border-color: map.get($palette, 'border');

    &:hover {
      background-color: map.get($palette, 'backgroundHover');
    }

    &:focus-visible,
    &:focus-within {
      background-color: map.get($palette, 'backgroundFocus');
    }

    &.is-disabled {
      color: map.get($palette, 'textDisabled');
      background-color: map.get($palette, 'backgroundDisabled');
      border-color: map.get($palette, 'borderDisabled');
    }
  }
}

Prefer placing disabled styles after interactive states instead of repeating :not(.is-disabled) on every manual hover or focus selector. Use :not(.is-disabled) only when disabled styles cannot reliably override an interactive selector, or when an existing component pattern requires it. Factory-generated states follow the factory implementation.

Tokens And Values

  • Use CSS variables from shared/stylesheets/themes/*.generated.scss.
  • Do not hard-code Design System colors, spacing, radius, shadows, or semantic visual values.
  • Use px() where Design System SCSS expects converted sizes.
  • Direct px values are allowed only where Stylelint permits them, such as borders, box-shadows, and outlines.
  • Reuse existing utilities, mixins, and foundations before adding new ones.
  • Use // ignore design_system_css_variables.rake only for intentional fallback or external variables, not as a normal escape hatch.

Selectors And Nesting

  • Prefer class selectors.
  • Avoid ID selectors and universal selectors.
  • Keep selectors flat where possible.
  • Do not use !important.
  • Keep nesting shallow; Stylelint enforces stricter Design System nesting rules.

Visual States

  • Style disabled, error, selected, pressed, hover, focus, and focus-visible states deliberately.
  • Keyboard focus must stay visible.
  • Hover must not be the only indication of selected, pressed, invalid, or disabled state.
  • Visual state selectors must match the template state source.

State Coupling

  • .is-disabled must match disabled behavior and disabled styling.
  • .is-error must match invalid or error styling.
  • .is-selected, [aria-selected="true"], and [aria-pressed="true"] must not drift from the real component state.
  • If an ARIA attribute is used as a styling hook, confirm that the ARIA semantics are valid for the widget pattern.

Example:

<li
  :class="{
    [DS_OPTION_LIST_CLASSES.item]: true,
    [DS_OPTION_LIST_CLASSES.selected]: isSelected,
    [DS_OPTION_LIST_CLASSES.disabled]: disabled,
  }"
  role="option"
  :aria-selected="isSelected"
  :aria-disabled="disabled || undefined"
>
  ...
</li>
.ds-option-list__item--selected {
  background-color: v-option-list.$item-background--selected;
}

The class controls the visual state, while ARIA exposes the semantic state. Both must use the same source of truth.

Verification

  • Run yarn stylelint for Design System SCSS changes when feasible.
  • Run bundle exec rake easyproject:design_system_css_variables after adding or changing CSS variable usage.
  • Check the affected Storybook story when visual states change.

Useful commands:

yarn stylelint
bundle exec rake easyproject:design_system_css_variables

yarn stylelint checks Design System CSS/SCSS and ignores stylesheets/temp. The CSS variable rake task is useful after adding or changing var(...) usage.