Skip to content

SCSS styling

A <style> block can opt into SCSS with a lang="scss" attribute. When it is present, the compiler runs the block through dart-sass at compile time — before the CSS scoping pass, before any target sees it. Every target then receives the same plain, already-compiled CSS, scoped exactly the way an ordinary <style> block is: React and Solid get hashed CSS-Module class names, Vue/Svelte/Angular get attribute-selector rewrites, Lit gets an adopted stylesheet. SCSS is an authoring convenience that has fully evaporated by the time the output reaches a framework — there is no per-target Sass runtime, and no lang="scss" attribute in any emitted file.

BadgeGridStyledScss.rozie is the programmatic-SCSS proving fixture: a static grid of status badges whose markup exists only to give the stylesheet real selectors to target. It deliberately exercises the SCSS surface that has no plain-CSS equivalent:

  • @use 'sass:map' — a built-in Sass module. (No filesystem @use — the compile configures no importer, so only Sass's own built-in modules resolve.)
  • a Sass map ($status-colors), iterated with @each and read with map.get
  • an @function with @if / @else control flow
  • @for, generating an indexed spacing-utility scale
  • a %placeholder pulled in with @extend
  • #{…} interpolation — used in both a selector and a property value

sass is an optional peer dependency

SCSS support needs the sass package. It is an optional peer of @rozie/core — install it only if you author <style lang="scss"> blocks (pnpm add -D sass). A lang="scss" block compiled with sass absent raises ROZ085; SCSS that dart-sass rejects raises ROZ086 with a dart-sass code frame.

Live demo

The grid below is the actual examples/BadgeGridStyledScss.rozie file, compiled by @rozie/unplugin/vite into a Vue SFC. Its three-column layout, badge padding, and colors are all produced by the SCSS @for, @function, and @each constructs — yet the emitted SFC carries nothing but plain CSS.

Source — BadgeGridStyledScss.rozie

rozie
<!--
  BadgeGridStyledScss.rozie — second SCSS dist-parity proving fixture
  (Phase 10, test-coverage gap closure 13th example).

  PortalListStyledScss already covers the "structural" SCSS surface:
  nesting, $variables, one @mixin/@include, the & parent-ref, :root and
  @portal. This sibling fixture deliberately covers the COMPLEMENT — the
  programmatic SCSS surface that otherwise has ZERO cross-target
  regression coverage:

    - @if / @else        — conditional declarations
    - @each              — iterate a Sass map
    - @for               — generate an indexed utility scale
    - @function          — a pure user-defined function
    - %placeholder + @extend
    - #{...}             — interpolation, in a selector AND a value
    - a Sass map + `@use 'sass:map'` (a BUILT-IN module — never a
      filesystem @use; no importer is configured)

  Determinism (the dist-parity gate is strict byte-identity):
    - Every computed value is integer-ish (integer px, plain hex). No
      decimal arithmetic, no deprecated color functions (darken / lighten
      etc. warn and their decimal output drifts across dart-sass patch
      releases).
    - Only the built-in `sass:map` module is used.

  The component itself is intentionally tiny — a static grid of status
  badges. The markup exists only to give the <style lang="scss"> block a
  natural set of class names to target.
-->

<rozie name="BadgeGridStyledScss">

<props>
{
  badges: { type: Array, default: () => [] }
}
</props>

<template>
<div class="badge-grid">
  <span r-for="badge in $props.badges" :key="badge" class="badge badge--neutral">
    {{ badge }}
  </span>
</div>
</template>

<style lang="scss">
// SCSS proving fixture (Phase 10) — programmatic surface.
//
// `@use` of a BUILT-IN module only. dart-sass resolves `sass:map` from its
// own bundle; no filesystem importer is configured for the dist-parity
// compile, so a `@use './partial'` would fail. `sass:map` is always safe.
@use 'sass:map';

// A Sass map — status name → token color. Iterated by @each below and
// read by map.get inside the @function.
$status-colors: (
  neutral: #6b7280,
  success: #16a34a,
  warning: #d97706,
  danger: #dc2626,
);

// Integer-only design scale. @for walks 1..3 to emit spacing utilities;
// keeping the step an integer keeps every computed px value exact across
// dart-sass patch versions.
$space-step: 4px;
$grid-columns: 3;

// %placeholder — shared badge skeleton. @extend-ed by `.badge` below; it
// must NOT appear on its own in the compiled CSS (placeholders are
// emit-only-when-extended).
%badge-base {
  display: inline-flex;
  align-items: center;
  border-radius: 4px;
  font-weight: 600;
}

// @function — pure integer math. Given a size step it returns the
// horizontal padding. The @if / @else picks a floor so the smallest
// step never collapses to 0.
@function badge-padding($step) {
  @if $step <= 0 {
    @return 4px;
  } @else {
    @return $step * 8px;
  }
}

.badge-grid {
  display: grid;
  // Interpolation in a property value — `#{$grid-columns}` splices the
  // raw token into the `repeat(...)` argument list.
  grid-template-columns: repeat(#{$grid-columns}, 1fr);
  gap: $space-step;
}

.badge {
  // @extend pulls every %badge-base declaration into `.badge`.
  @extend %badge-base;
  padding: 2px badge-padding(1);
}

// @each over the map — interpolation in the SELECTOR (`.badge--#{$name}`)
// generates one variant rule per map entry; map.get re-reads the color so
// the loop proves both map iteration and keyed lookup.
@each $name, $color in $status-colors {
  .badge--#{$name} {
    color: #ffffff;
    background: map.get($status-colors, $name);
  }
}

// @for — indexed utility scale. 1..3 inclusive (`through`); each step
// multiplies the integer $space-step so all four px outputs are exact.
@for $i from 1 through $grid-columns {
  .badge-grid--gap-#{$i} {
    gap: $space-step * $i;
  }
}
</style>

</rozie>

Compiled output

vue
<template>

<div class="badge-grid" v-bind="$attrs">
  <span v-for="badge in props.badges" :key="badge" class="badge badge--neutral">
    {{ badge }}
  </span>
</div>

</template>

<script setup lang="ts">
const props = withDefaults(
  defineProps<{ badges?: any[] }>(),
  { badges: () => [] }
);
</script>

<style scoped>
.badge {
  display: inline-flex;
  align-items: center;
  border-radius: 4px;
  font-weight: 600;
}
.badge-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 4px;
}
.badge {
  padding: 2px 8px;
}
.badge--neutral {
  color: #ffffff;
  background: #6b7280;
}
.badge--success {
  color: #ffffff;
  background: #16a34a;
}
.badge--warning {
  color: #ffffff;
  background: #d97706;
}
.badge--danger {
  color: #ffffff;
  background: #dc2626;
}
.badge-grid--gap-1 {
  gap: 4px;
}
.badge-grid--gap-2 {
  gap: 8px;
}
.badge-grid--gap-3 {
  gap: 12px;
}
</style>
tsx
import { useState } from 'react';
import { clsx, rozieDisplay } from '@rozie/runtime-react';
import './BadgeGridStyledScss.css';

interface BadgeGridStyledScssProps {
  badges?: any[];
}

export default function BadgeGridStyledScss(_props: BadgeGridStyledScssProps): JSX.Element {
  const __defaultBadges = useState(() => (() => [])())[0];
  const props: Omit<BadgeGridStyledScssProps, 'badges'> & { badges: any[] } = {
    ..._props,
    badges: _props.badges ?? __defaultBadges,
  };
  const attrs: Record<string, unknown> = (() => {
    const { badges, ...rest } = _props as BadgeGridStyledScssProps & Record<string, unknown>;
    void badges;
    return rest;
  })();

  return (
    <>
    <div {...attrs} className={clsx("badge-grid", (attrs.className as string | undefined))} data-rozie-s-44801268="">
      {props.badges.map((badge) => <span key={badge} className={"badge badge--neutral"} data-rozie-s-44801268="">
        {rozieDisplay(badge)}
      </span>)}
    </div>
    </>
  );
}
svelte
<script lang="ts">
import { applyListeners, rozieDisplay } from '@rozie/runtime-svelte';

interface Props {
  badges?: any[];
  [key: string]: unknown;
}

let __defaultBadges = (() => [])();

let { badges = __defaultBadges, ...__rozieAttrs }: Props = $props();
</script>

<div {...__rozieAttrs} class={["badge-grid", (__rozieAttrs)?.class]} use:applyListeners={__rozieAttrs} data-rozie-s-44801268>{#each badges as badge (badge)}<span class="badge badge--neutral" data-rozie-s-44801268>{rozieDisplay(badge)}</span>{/each}</div>

<style>
:global {
  .badge[data-rozie-s-44801268] {
    display: inline-flex;
    align-items: center;
    border-radius: 4px;
    font-weight: 600;
  }
  .badge-grid[data-rozie-s-44801268] {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: 4px;
  }
  .badge[data-rozie-s-44801268] {
    padding: 2px 8px;
  }
  .badge--neutral[data-rozie-s-44801268] {
    color: #ffffff;
    background: #6b7280;
  }
  .badge--success[data-rozie-s-44801268] {
    color: #ffffff;
    background: #16a34a;
  }
  .badge--warning[data-rozie-s-44801268] {
    color: #ffffff;
    background: #d97706;
  }
  .badge--danger[data-rozie-s-44801268] {
    color: #ffffff;
    background: #dc2626;
  }
  .badge-grid--gap-1[data-rozie-s-44801268] {
    gap: 4px;
  }
  .badge-grid--gap-2[data-rozie-s-44801268] {
    gap: 8px;
  }
  .badge-grid--gap-3[data-rozie-s-44801268] {
    gap: 12px;
  }
}
</style>
ts
import { Component, DestroyRef, ElementRef, Renderer2, ViewEncapsulation, afterRenderEffect, effect, inject, input, viewChild } from '@angular/core';

function __rozieDisplay(v: unknown): string {
  if (v == null) return '';
  if (typeof v === 'string') return v;
  if (typeof v === 'object') {
    try {
      return JSON.stringify(v, null, 2);
    } catch {
      // Circular structure or a non-serialisable value (BigInt nested in an
      // object). Degrade to a non-throwing form so the wrap never crashes the
      // render — that is the entire point of "safe" interpolation (SPEC-1).
      return String(v);
    }
  }
  return String(v);
}

function __rozieAttr(v: unknown): string | null {
  return v == null ? null : __rozieDisplay(v);
}

@Component({
  selector: 'rozie-badge-grid-styled-scss',
  standalone: true,
  template: `

    <div class="badge-grid" #rozieSpread_0 #rozieListenersTarget_1>
      @for (badge of badges(); track badge) {
    <span class="badge badge--neutral">
        {{ rozieDisplay(badge) }}
      </span>
    }
    </div>

  `,
  styles: [`
    .badge {
      display: inline-flex;
      align-items: center;
      border-radius: 4px;
      font-weight: 600;
    }
    .badge-grid {
      display: grid;
      grid-template-columns: repeat(3, 1fr);
      gap: 4px;
    }
    .badge {
      padding: 2px 8px;
    }
    .badge--neutral {
      color: #ffffff;
      background: #6b7280;
    }
    .badge--success {
      color: #ffffff;
      background: #16a34a;
    }
    .badge--warning {
      color: #ffffff;
      background: #d97706;
    }
    .badge--danger {
      color: #ffffff;
      background: #dc2626;
    }
    .badge-grid--gap-1 {
      gap: 4px;
    }
    .badge-grid--gap-2 {
      gap: 8px;
    }
    .badge-grid--gap-3 {
      gap: 12px;
    }
  `],
})
export class BadgeGridStyledScss {
  badges = input<any[]>((() => [])());

  private __rozieDestroyRef = inject(DestroyRef);

  private rozieSpread_0 = viewChild<ElementRef>('rozieSpread_0');

  private __rozieApplyAttrs = (() => {
    const renderer = inject(Renderer2);
    const prevKeysByElement = new WeakMap<HTMLElement, string[]>();
    const prevClassTokensByElement = new WeakMap<HTMLElement, string[]>();
    const prevStylePropsByElement = new WeakMap<HTMLElement, string[]>();
    const parseClassTokens = (value: unknown): string[] => {
      if (typeof value !== 'string') return [];
      const out: string[] = [];
      for (const tok of value.split(/\s+/)) {
        if (tok.length > 0) out.push(tok);
      }
      return out;
    };
    const parseStyleDecls = (value: unknown): Array<[string, string]> => {
      if (typeof value !== 'string') return [];
      const out: Array<[string, string]> = [];
      for (const decl of value.split(';')) {
        const colon = decl.indexOf(':');
        if (colon < 0) continue;
        const prop = decl.slice(0, colon).trim();
        const val = decl.slice(colon + 1).trim();
        if (prop.length > 0) out.push([prop, val]);
      }
      return out;
    };
    const applyClassMerge = (el: HTMLElement, value: unknown) => {
      const next = parseClassTokens(value);
      const prev = prevClassTokensByElement.get(el) ?? [];
      const nextSet = new Set(next);
      for (const tok of prev) {
        if (!nextSet.has(tok)) el.classList.remove(tok);
      }
      for (const tok of next) el.classList.add(tok);
      prevClassTokensByElement.set(el, next);
    };
    const applyStyleMerge = (el: HTMLElement, value: unknown) => {
      const next = parseStyleDecls(value);
      const prev = prevStylePropsByElement.get(el) ?? [];
      const nextProps = next.map(([p]) => p);
      const nextSet = new Set(nextProps);
      for (const prop of prev) {
        if (!nextSet.has(prop)) el.style.removeProperty(prop);
      }
      for (const [prop, val] of next) el.style.setProperty(prop, val, 'important');
      prevStylePropsByElement.set(el, nextProps);
    };
    return (el: HTMLElement, obj: Record<string, unknown> | null | undefined) => {
      const safeObj: Record<string, unknown> = obj ?? {};
      const prevKeys = prevKeysByElement.get(el) ?? [];
      for (const k of prevKeys) {
        if (k === 'class' || k === 'style') continue;
        if (!(k in safeObj)) renderer.removeAttribute(el, k);
      }
      if (!('class' in safeObj) && prevClassTokensByElement.has(el)) {
        applyClassMerge(el, '');
      }
      if (!('style' in safeObj) && prevStylePropsByElement.has(el)) {
        applyStyleMerge(el, '');
      }
      for (const [k, v] of Object.entries(safeObj)) {
        if (k === 'class') {
          applyClassMerge(el, v);
        } else if (k === 'style') {
          applyStyleMerge(el, v);
        } else if (v === null || v === false) {
          renderer.removeAttribute(el, k);
        } else {
          renderer.setAttribute(el, k, String(v));
        }
      }
      prevKeysByElement.set(el, Object.keys(safeObj));
    };
  })();

  private __rozieGetHostAttrs = (() => {
    const host = inject(ElementRef);
    return () => {
      const el = host.nativeElement as HTMLElement;
      const out: Record<string, unknown> = {};
      for (const a of Array.from(el.attributes)) out[a.name] = a.value;
      return out;
    };
  })();

  private __rozieSpread_0_effect = afterRenderEffect(() => {
    const el = this.rozieSpread_0()?.nativeElement;
    if (!el) return;
    this.__rozieApplyAttrs(el, this.__rozieGetHostAttrs());
  });

  private rozieListenersTarget_1 = viewChild<ElementRef>('rozieListenersTarget_1');

  private __rozieListenersRenderer = inject(Renderer2);

  private __rozieListenersDisposers_1: Array<() => void> = [];

  private __rozieListenersDestroyRegistered_1 = false;

  private __rozieListenersEffect_1 = effect(() => {
    const el = this.rozieListenersTarget_1()?.nativeElement;
    if (!el) return;
    for (const off of this.__rozieListenersDisposers_1) off();
    this.__rozieListenersDisposers_1 = [];
    const obj: Record<string, unknown> = {};
    for (const [k, v] of Object.entries(obj)) {
      if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
      if (typeof v !== 'function') continue;
      const norm = k.startsWith('on') ? k.slice(2).toLowerCase() : k;
      const dispose = this.__rozieListenersRenderer.listen(el, norm, v as EventListener);
      this.__rozieListenersDisposers_1.push(dispose);
    }
    if (!this.__rozieListenersDestroyRegistered_1) {
      this.__rozieListenersDestroyRegistered_1 = true;
      this.__rozieDestroyRef.onDestroy(() => {
        for (const off of this.__rozieListenersDisposers_1) off();
        this.__rozieListenersDisposers_1 = [];
      });
    }
  });

  rozieDisplay(v: unknown): string { return __rozieDisplay(v); }

  rozieAttr(v: unknown): string | null { return __rozieAttr(v); }
}

export default BadgeGridStyledScss;
tsx
import type { JSX } from 'solid-js';
import { For, mergeProps, splitProps } from 'solid-js';
import { __rozieInjectStyle, rozieDisplay } from '@rozie/runtime-solid';

__rozieInjectStyle('BadgeGridStyledScss-44801268', `.badge[data-rozie-s-44801268] {
  display: inline-flex;
  align-items: center;
  border-radius: 4px;
  font-weight: 600;
}
.badge-grid[data-rozie-s-44801268] {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 4px;
}
.badge[data-rozie-s-44801268] {
  padding: 2px 8px;
}
.badge--neutral[data-rozie-s-44801268] {
  color: #ffffff;
  background: #6b7280;
}
.badge--success[data-rozie-s-44801268] {
  color: #ffffff;
  background: #16a34a;
}
.badge--warning[data-rozie-s-44801268] {
  color: #ffffff;
  background: #d97706;
}
.badge--danger[data-rozie-s-44801268] {
  color: #ffffff;
  background: #dc2626;
}
.badge-grid--gap-1[data-rozie-s-44801268] {
  gap: 4px;
}
.badge-grid--gap-2[data-rozie-s-44801268] {
  gap: 8px;
}
.badge-grid--gap-3[data-rozie-s-44801268] {
  gap: 12px;
}`);

interface BadgeGridStyledScssProps {
  badges?: any[];
}

export default function BadgeGridStyledScss(_props: BadgeGridStyledScssProps): JSX.Element {
  const _merged = mergeProps({ badges: (() => [])() }, _props);
  const [local, attrs] = splitProps(_merged, ['badges']);

  return (
    <>
    <div {...attrs} class={"badge-grid" + (((attrs as unknown as Record<string, unknown>).class as string | undefined) ? " " + ((attrs as unknown as Record<string, unknown>).class as string | undefined) : "")} data-rozie-s-44801268="">
      <For each={local.badges}>{(badge) => <span class={"badge badge--neutral"} data-rozie-s-44801268="">
        {rozieDisplay(badge)}
      </span>}</For>
    </div>
    </>
  );
}
ts
import { LitElement, css, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { SignalWatcher } from '@lit-labs/preact-signals';
import { rozieAttr, rozieDisplay, rozieListeners, rozieSpread } from '@rozie/runtime-lit';
import { repeat } from 'lit/directives/repeat.js';

@customElement('rozie-badge-grid-styled-scss')
export default class BadgeGridStyledScss extends SignalWatcher(LitElement) {
  static styles = css`
.badge[data-rozie-s-44801268] {
  display: inline-flex;
  align-items: center;
  border-radius: 4px;
  font-weight: 600;
}
.badge-grid[data-rozie-s-44801268] {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 4px;
}
.badge[data-rozie-s-44801268] {
  padding: 2px 8px;
}
.badge--neutral[data-rozie-s-44801268] {
  color: #ffffff;
  background: #6b7280;
}
.badge--success[data-rozie-s-44801268] {
  color: #ffffff;
  background: #16a34a;
}
.badge--warning[data-rozie-s-44801268] {
  color: #ffffff;
  background: #d97706;
}
.badge--danger[data-rozie-s-44801268] {
  color: #ffffff;
  background: #dc2626;
}
.badge-grid--gap-1[data-rozie-s-44801268] {
  gap: 4px;
}
.badge-grid--gap-2[data-rozie-s-44801268] {
  gap: 8px;
}
.badge-grid--gap-3[data-rozie-s-44801268] {
  gap: 12px;
}
`;

  @property({ type: Array }) badges: any[] = [];

  private _disconnectCleanups: Array<() => void> = [];
  // Re-parenting guard: set true once the deferred teardown has actually
  // run (a genuine un-mount), so a subsequent reconnect knows to re-arm.
  private _rozieTornDown = false;

  disconnectedCallback(): void {
    super.disconnectedCallback();
    queueMicrotask(() => {
      if (this.isConnected || this._rozieTornDown) return;
      this._rozieTornDown = true;
      for (const fn of this._disconnectCleanups) fn();
      this._disconnectCleanups = [];
    });
  }

  render() {
    return html`
<div class="badge-grid" ${rozieSpread(this.$attrs)} ${rozieListeners(this.$listeners)} data-rozie-s-44801268>
  ${repeat<any>(this.badges, (badge, _idx) => badge, (badge, _idx) => html`<span class="badge badge--neutral" key=${rozieAttr(badge)} data-rozie-s-44801268>
    ${rozieDisplay(badge)}
  </span>`)}
</div>
`;
  }

  /**
   * Plan 14-05 — cross-framework attribute fallthrough source. Reads the
   * host custom element's attributes on each call so a consumer-side bound
   * attribute flows through on every render. The `rozieSpread` directive
   * (D-02) does the cross-render diff downstream.
   *
   * Phase 15 follow-up Bug A — declared-prop attribute names are filtered
   * out so `$attrs` returns "rest after declared props" (semantic parity
   * with React/Vue/Svelte/Solid/Angular). Both Lit attribute-naming
   * forms are folded into the skip set: kebab-case for model props
   * (explicit `attribute:`) AND lowercased property name (Lit's default).
   */
  private get $attrs(): Record<string, string> {
    const __skip = new Set<string>(['badges']);
    const out: Record<string, string> = {};
    for (const a of Array.from(this.attributes)) {
      if (__skip.has(a.name)) continue;
      out[a.name] = a.value;
    }
    return out;
  }

  /**
   * Phase 15 D-19 — consumer-passed listener cluster placeholder.
   * Lit attaches event listeners directly on the host element via
   * `addEventListener` (no per-instance prop rest binding), so the
   * runtime value is undefined; the `rozieListeners` directive's
   * nullish coercion (`obj ?? {}`) handles the no-op cleanly.
   * The declaration exists to satisfy `tsc --noEmit` on consumer
   * projects with strict mode — bare `$listeners` in `render()`
   * would otherwise raise TS2304 (Cannot find name).
   */
  private get $listeners(): Record<string, EventListener> | undefined {
    return undefined;
  }
}

Pre-v1.0 — internal monorepo.