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';
import { createRozieAttrApplier, createRozieHostAttrsReader, rozieAttr as __rozieAttr, rozieDisplay as __rozieDisplay } from '@rozie/runtime-angular';
@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: [`
:host(rozie-badge-grid-styled-scss) { display: contents; }
.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 = createRozieAttrApplier(inject(Renderer2));
private __rozieGetHostAttrs = createRozieHostAttrsReader(inject(ElementRef));
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 { mergeProps, splitProps } from 'solid-js';
import { Key } from '@solid-primitives/keyed';
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: (() => [])() as any[] }, _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="">
<Key each={local.badges as readonly any[]} by={(badge) => badge}>{(badge) => <span class={"badge badge--neutral"} data-rozie-s-44801268="">
{rozieDisplay(badge())}
</span>}</Key>
</div>
</>
);
}ts
import { LitElement, css, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { SignalWatcher } from '@lit-labs/preact-signals';
import { 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`
:host{display:contents}
.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" 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).
*
* command-palette-per-level-virtual / portal-through-portal cluster —
* `data-rozie-ref` is ALWAYS skipped too (a reserved compiler bookkeeping
* attribute, never a consumer prop) so a parent-assigned `ref=` on this
* component's own host tag can never clobber this component's OWN
* internal `data-rozie-ref` ref markers via fallthrough re-application.
*/
private get $attrs(): Record<string, string> {
const __skip = new Set<string>(['data-rozie-ref', '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;
}
}