Appearance
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@eachand read withmap.get - an
@functionwith@if/@elsecontrol flow @for, generating an indexed spacing-utility scale- a
%placeholderpulled 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;
}
}