After lesson 2.1.4, the Kladde’s stylesheet has just under 280 lines, and it will grow with every page. In a single file, you lose the overview, and some things appear twice: The width 48rem shows up again in every media query, and rules for the navigation repeat .site-nav in every selector. SCSS solves that. It’s CSS with a few extensions that are compiled to ordinary CSS before serving, because browsers only understand CSS.

In this lesson, the prototype moves into its first real web project, src/Kladde.Web. It serves the pages and compiles the SCSS on every dotnet build. Afterward, the Kladde looks exactly as before but is built better. From module 2.3 on, the web API goes into the same project, and from module 2.5 on the interface in Blazor.

What SCSS is

Sass is a language for stylesheets, SCSS its notation with curly braces and semicolons that looks like CSS. Every valid CSS file is already valid SCSS. So you can start with your existing stylesheet and use the extensions step by step. The four most important ones:

  • Variables that are substituted at compile time,
  • nesting of rules,
  • partials: partial files that are put together into one stylesheet with @use,
  • mixins: named building blocks of declarations that you insert in several places.

Compiling is done with Dart Sass, the implementation that the Sass project itself maintains. In a .NET project, the NuGet package AspNetCore.SassCompiler (MIT license, not from Microsoft) brings it along, as a ready-made program for Windows, Linux and macOS. You don’t need Node.js for that. The package hooks into dotnet build and dotnet publish: First it compiles, then it builds.

The generated CSS does not belong in the repository. It’s created anew from the SCSS on every build. If it were in Git, sooner or later somebody would change it by hand, and the change would be gone at the next build.

Variables: Sass or custom property?

A Sass variable begins with $:

SCSS
$wide: 48rem;

@media (min-width: $wide) {
  h1 {
    font-size: 2.5rem;
  }
}

At compile time, Dart Sass substitutes the value; the finished CSS says @media (min-width: 48rem). The variable itself no longer exists in the browser.

That’s the difference to the custom properties from lesson 2.1.4 (--color-text): They exist at runtime in the browser, they’re inherited, and you can reassign them in a media query. The dark scheme is based on that. A simple rule follows:

  • Colors, spacing, fonts and everything that can change at runtime stay custom properties. The dark scheme only works that way.
  • You use Sass variables for what custom properties can’t do. The most important example: A media query can’t read a custom property. @media (min-width: var(--wide)) has no effect. The width of the wide layout therefore becomes a Sass variable.

Nesting, in moderation

In SCSS, rules may stand inside rules. The outer selector is put in front of the inner ones:

SCSS
.site-nav {
  ul {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-1) var(--space-3);
    margin: 0;
    padding: 0;
    list-style: none;
  }

  a {
    display: inline-block;
    padding: var(--space-2) var(--space-1);

    &[aria-current="page"] {
      font-weight: 700;
      text-decoration-thickness: 3px;
    }
  }
}

Dart Sass turns that into:

CSS
.site-nav ul {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-1) var(--space-3);
  margin: 0;
  padding: 0;
  list-style: none;
}
.site-nav a {
  display: inline-block;
  padding: var(--space-2) var(--space-1);
}
.site-nav a[aria-current=page] {
  font-weight: 700;
  text-decoration-thickness: 3px;
}

The & stands for the selector around it. So &[aria-current="page"] means “this a, if it also has the attribute”, with no space in between. Without the &, it would say .site-nav a [aria-current=page], with a space: an element with the attribute inside a link, and there’s no such thing. You write &:hover or &:focus-visible the same way. That Dart Sass leaves out the quotation marks around page is valid CSS and changes nothing.

Nest in moderation:

  • At most three levels. Every level lengthens the selector and raises its specificity. Deeply nested SCSS produces exactly the overlong, hard-to-override selectors that lesson 2.1.4 wanted to avoid.
  • Don’t mirror the HTML. main { section { div { p { … } } } } ties the stylesheet to the exact structure of the page; one extra div in the HTML, and nothing applies anymore. Nest inside a building block (.site-nav, .card, .field), not across the page.

Partials and @use

A file whose name begins with an underscore is a partial: a part that isn’t compiled on its own but included in another stylesheet. The Kladde gets five partials and one file that puts them together:

src/Kladde.Web/Styles/
├── kladde.scss      puts everything together; becomes wwwroot/css/kladde.css
├── _tokens.scss     custom properties, dark scheme, the width $wide, the mixin wide
├── _base.scss       box-sizing, font, headings, links, focus, images
├── _layout.scss     header, navigation, main content, footer, cards
├── _tables.scss     tables
└── _forms.scss      fields, groups, hints, errors, buttons
SCSS
// kladde.scss: puts the Kladde's stylesheet together from its parts
@use "tokens";
@use "base";
@use "layout";
@use "tables";
@use "forms";

@use "tokens" loads _tokens.scss; you leave out the underscore and the extension. What the partial offers in variables and mixins, you reach through its name as a namespace: tokens.$wide, tokens.wide. Every partial that needs something from another one writes its own @use at its start. Dart Sass still loads each partial only once, and its CSS appears only once in the result, at the place where it was loaded first. That’s why @use "tokens" comes first in kladde.scss: That puts the custom properties at the top of the finished stylesheet.

Comments with // are notes for the SCSS and are missing from the finished CSS. Comments with /* … */ are carried over by Dart Sass into the verbose output.

In older guides, you’ll find @import instead of @use. @import is deprecated: It has no namespaces, loads files several times, and Dart Sass reports it with a warning because it goes away in the next major version. Always use @use.

Mixins: the media query in one place

A mixin is a named building block that you insert with @include. With @content, it takes in a whole block. That fits the wide layout perfectly:

SCSS
// in _tokens.scss
$wide: 48rem;

@mixin wide {
  @media (min-width: $wide) {
    @content;
  }
}
SCSS
// in _base.scss
@use "tokens";

h1 {
  font-size: 2rem;
  margin: 0 0 var(--space-3);

  @include tokens.wide {
    font-size: 2.5rem;
  }
}
CSS
h1 {
  font-size: 2rem;
  margin: 0 0 var(--space-3);
}
@media (min-width: 48rem) {
  h1 {
    font-size: 2.5rem;
  }
}

The rule for the wide layout now stands right next to the base rule, no longer in a block at the end of the file. Whoever changes h1 sees both at a glance. And if the wide layout should ever begin at 50rem, you change one line in _tokens.scss.

Mixins can also have parameters and contain declarations. Use them sparingly: A mixin copies its declarations to every place where it’s inserted. If two rules share the same values, a selector list like .field-hint, .field-error is often the simpler solution.

The web project Kladde.Web

Until now, the pages lived in prototype/ and needed the form server from your practice folder. Now the Kladde gets a web project of its own. In the Kladde’s folder:

Bash
dotnet new web -o src/Kladde.Web
dotnet sln add src/Kladde.Web/Kladde.Web.csproj

dotnet new web creates the emptiest of all web projects: a project file with Microsoft.NET.Sdk.Web, a Program.cs with a “Hello World!” endpoint, appsettings.json and Properties/launchSettings.json with the ports for dotnet run. You adapt two things to the Kladde’s house rules:

  1. In Kladde.Web.csproj, delete the line <Nullable>enable</Nullable>. This rule applies to all projects in Directory.Build.props; since the Tools module in K1, no project sets it itself.
  2. Replace the content of Program.cs:
C#
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.UseDefaultFiles();
app.UseStaticFiles();

app.Run();

You know this from the form server: UseStaticFiles serves the files, and UseDefaultFiles shows the index.html for /. Without a folder given, both use the folder wwwroot in the project. In module 2.3, you’ll learn what these lines do exactly and in which order they have to stand.

The pages and the logo move with git mv, so that Git recognizes them as moved and keeps their history:

Bash
mkdir src/Kladde.Web/wwwroot
git mv prototype/index.html prototype/contacts.html prototype/appointment.html prototype/appointment-error.html prototype/logo.svg src/Kladde.Web/wwwroot/

Start the Kladde:

Bash
dotnet run --project src/Kladde.Web
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://localhost:5238
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
      Hosting environment: Development
info: Microsoft.Hosting.Lifetime[0]
      Content root path: /home/ada/kladde/src/Kladde.Web

dotnet new chose the port at random; you’ll see a different number, and the path is a different one too. Open the address in the browser.

SCSS in the build

Add the package:

Bash
dotnet add src/Kladde.Web package AspNetCore.SassCompiler --version 1.107.1

Without any further setting, the package compiles on every build all SCSS files from the project’s Styles folder to wwwroot/css, except the partials. Styles/kladde.scss becomes wwwroot/css/kladde.css, and the pages already include exactly that file. In addition, kladde.css.map is created, a source map: With it, the DevTools show in Styles not kladde.css:96 but the SCSS file and line a rule comes from, such as _layout.scss:30.

You write the settings into a file sasscompiler.json in the project folder. For the Kladde, this is enough:

JSON
{
  "Source": "Styles",
  "Target": "wwwroot/css",
  "Configurations": {
    "Release": {
      "Arguments": "--style=compressed --no-source-map"
    }
  }
}

Source and Target repeat the defaults so that everybody sees them. Under Configurations is what applies only to a build with -c Release: Then Dart Sass writes the CSS without spaces and line breaks and without a source map, the way it’s served later. With the ordinary dotnet build (Debug), it stays easy to read.

Finally, the folder with the generated CSS belongs in the Kladde’s .gitignore:

# Kladde: generated by AspNetCore.SassCompiler in the build
src/Kladde.Web/wwwroot/css/

After a build, git check-ignore -v src/Kladde.Web/wwwroot/css/kladde.css shows which line excludes the file.

After every change to the SCSS, you build again: dotnet build, or you stop dotnet run with Ctrl+C and start it again, because dotnet run builds before it starts. Then reload the page. If the browser still holds the old file in its cache, “Disable cache” from lesson 2.1.5 helps.

When the SCSS is wrong

An error in the SCSS makes the build fail, like an error in the C# code. If, for example, the semicolon after width: 100% is missing in _tables.scss, dotnet build reports (shortened here; on your machine, the path of the package’s .targets file stands in front of every line):

error : Error running sass compiler: Error: expected ";".
error :   ╷
error : 5 │   border-collapse: collapse;
error :   │                  ^
error :   ╵
error :   Styles\_tables.scss 5:18  @use
error :   Styles\kladde.scss 5:1    root stylesheet
Build failed with 1 error(s) in 2.3s

Read it like a compiler message from K0: file _tables.scss, line 5, character 18, a ; was expected. Dart Sass noticed the error only in the line after the missing semicolon, because until there the declaration could have gone on. The last line shows the way: kladde.scss loaded _tables.scss in line 5.

A second typical error: A partial uses tokens.wide but has no @use "tokens" of its own:

error : Error running sass compiler: Error: There is no module with namespace "tokens".

That kladde.scss has already loaded the tokens doesn’t help: Every partial sees only what it loads itself with @use. That way, it stays visible in every file what it depends on.

Exercise 1: Kladde.Web serves the prototype

On a new branch kladde-web, create the project src/Kladde.Web as described above: template, solution, the Nullable line gone, Program.cs with static files, the four pages and the logo moved to wwwroot with git mv. For now, you leave the stylesheet in prototype/css/.

Start the Kladde with dotnet run --project src/Kladde.Web and check:

  1. All four pages appear, and the links between them work.
  2. The pages are unstyled. In the Network tab, kladde.css has which status code, and why?
  3. dotnet build ends without a warning, and the check script check.ps1 from K1 (format and build) ends with exit code 0.

Solution 1

  1. The pages appear at the address from the output of dotnet run, the home page at /.
  2. kladde.css comes with 404: The pages ask for css/kladde.css, that is, wwwroot/css/kladde.css, and that doesn’t exist yet. The Console reports it too. This very file is what the build will generate in a moment.
  3. If check.ps1 reports something about Program.cs, it’s usually the line ending of the last line: The .editorconfig from K1 determines whether a file ends with a line break, and your editor may have done it differently. dotnet format Kladde.slnx tidies that up; afterward, check.ps1 ends with 0.

The project file then looks like this (the package’s version is added in exercise 2):

XML
<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

</Project>

Exercise 2: The stylesheet in partials

Move the stylesheet with git mv prototype/css/kladde.css src/Kladde.Web/Styles/kladde.scss and split it into the five partials from the section “Partials and @use”. In doing so:

  • The width 48rem becomes the Sass variable $wide in _tokens.scss, plus the mixin wide.
  • The “Wider screens” block at the end goes away: Each of its rules moves with @include tokens.wide to its base rule.
  • The rules of .site-nav, .site-footer, .card, .field, .choice and .error-summary are nested, at most three levels deep.
  • kladde.scss contains only the five @use now.

Add the package, create sasscompiler.json and extend the .gitignore. Delete the empty folder prototype.

The exercise is checked when all four points are met:

  • dotnet build ends without a warning and creates src/Kladde.Web/wwwroot/css/kladde.css.
  • git status doesn’t show this CSS.
  • In the Network tab, kladde.css comes with 200.
  • Every page looks exactly as before. Compare your screenshots from lesson 2.1.4 with new ones at 360 and 1280 pixels, light and dark, page by page. If something differs, a rule landed in the wrong place while nesting: Thanks to the source map, the DevTools show which partial the rule comes from.

Solution 2

_tokens.scss:

SCSS
// _tokens.scss: all design values in one place

// Media queries can't read custom properties, which is why the width is a Sass variable.
$wide: 48rem;

:root {
  color-scheme: light dark;
  --color-text: #1d2422;
  --color-muted: #4a5552;
  --color-surface: #fbfaf7;
  --color-surface-raised: #ffffff;
  --color-border: #c9cfcc;
  --color-accent: #1f5f5b;
  --color-on-accent: #ffffff;
  --color-link: #1a5c8a;
  --color-focus: #b35c00;
  --color-danger: #a4262c;
  --space-1: 0.25rem;
  --space-2: 0.5rem;
  --space-3: 1rem;
  --space-4: 1.5rem;
  --space-5: 2.5rem;
  --radius: 0.375rem;
  --font-body: system-ui, "Segoe UI", Roboto, sans-serif;
  --measure: 68ch;
}

@media (prefers-color-scheme: dark) {
  :root {
    --color-text: #e6ebe9;
    --color-muted: #b4bfbb;
    --color-surface: #141a19;
    --color-surface-raised: #1d2524;
    --color-border: #3d4a47;
    --color-accent: #7cc4bd;
    --color-on-accent: #0d1413;
    --color-link: #8cc4f0;
    --color-focus: #ffb366;
    --color-danger: #ff8a8f;
  }
}

// Rules that apply only from the wide layout on.
@mixin wide {
  @media (min-width: $wide) {
    @content;
  }
}

_base.scss:

SCSS
// _base.scss: the basics for all pages
@use "tokens";

*,
*::before,
*::after {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family: var(--font-body);
  font-size: 1rem;
  line-height: 1.5;
  color: var(--color-text);
  background: var(--color-surface);
}

h1,
h2 {
  line-height: 1.2;
}

h1 {
  font-size: 2rem;
  margin: 0 0 var(--space-3);

  @include tokens.wide {
    font-size: 2.5rem;
  }
}

h2 {
  font-size: 1.375rem;
  margin: var(--space-4) 0 var(--space-2);
}

p {
  max-width: var(--measure);
}

a {
  color: var(--color-link);
}

:focus-visible {
  outline: 3px solid var(--color-focus);
  outline-offset: 2px;
}

img {
  max-width: 100%;
  height: auto;
}

_layout.scss:

SCSS
// _layout.scss: header, navigation, main content, footer and cards
@use "tokens";

.site-header {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2) var(--space-4);
  padding: var(--space-3);
  border-bottom: 1px solid var(--color-border);

  @include tokens.wide {
    justify-content: space-between;
    padding-inline: var(--space-4);
  }
}

.site-nav {
  ul {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-1) var(--space-3);
    margin: 0;
    padding: 0;
    list-style: none;
  }

  a {
    display: inline-block;
    padding: var(--space-2) var(--space-1);

    &[aria-current="page"] {
      font-weight: 700;
      text-decoration-thickness: 3px;
    }
  }
}

main {
  max-width: 60rem;
  margin: 0 auto;
  padding: var(--space-4) var(--space-3) var(--space-5);
}

.site-footer {
  padding: var(--space-3);
  border-top: 1px solid var(--color-border);
  color: var(--color-muted);
  font-size: 0.875rem;

  p {
    margin: 0;
  }
}

.cards {
  display: grid;
  gap: var(--space-3);

  @include tokens.wide {
    grid-template-columns: repeat(2, 1fr);
  }
}

.card {
  padding: var(--space-3);
  background: var(--color-surface-raised);
  border: 1px solid var(--color-border);
  border-radius: var(--radius);

  h2 {
    margin-top: 0;
  }
}

_tables.scss:

SCSS
// _tables.scss: tables like the contact list

table {
  width: 100%;
  border-collapse: collapse;
}

caption {
  padding-bottom: var(--space-2);
  font-weight: 700;
  text-align: start;
}

th,
td {
  padding: var(--space-2);
  border-bottom: 1px solid var(--color-border);
  text-align: start;
  vertical-align: top;
}

thead th {
  border-bottom-width: 2px;
}

td a {
  overflow-wrap: anywhere;
}

_forms.scss:

SCSS
// _forms.scss: fields, groups, hints, errors and buttons

fieldset {
  margin: 0 0 var(--space-4);
  padding: var(--space-3);
  border: 1px solid var(--color-border);
  border-radius: var(--radius);
}

legend {
  padding: 0 var(--space-1);
  font-weight: 700;
}

.field {
  margin-bottom: var(--space-3);

  label {
    display: block;
    margin-bottom: var(--space-1);
    font-weight: 600;
  }

  input,
  select,
  textarea {
    width: 100%;
    max-width: 30rem;
    padding: var(--space-2);
    font: inherit;
    color: inherit;
    background: var(--color-surface-raised);
    border: 1px solid var(--color-muted);
    border-radius: var(--radius);
  }

  [aria-invalid="true"] {
    border: 2px solid var(--color-danger);
  }
}

.choice {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  margin-bottom: var(--space-2);

  input {
    width: 1.25rem;
    height: 1.25rem;
    margin: 0;
    accent-color: var(--color-accent);
  }
}

.field-hint,
.field-error {
  margin: var(--space-1) 0 0;
  font-size: 0.875rem;
}

.field-hint {
  color: var(--color-muted);
}

.field-error {
  color: var(--color-danger);
  font-weight: 700;
}

.error-summary {
  margin-bottom: var(--space-4);
  padding: var(--space-3);
  border: 3px solid var(--color-danger);
  border-radius: var(--radius);

  h2 {
    margin-top: 0;
  }
}

button {
  padding: var(--space-2) var(--space-4);
  font: inherit;
  font-weight: 700;
  color: var(--color-on-accent);
  background: var(--color-accent);
  border: 0;
  border-radius: var(--radius);
  cursor: pointer;
}

kladde.scss contains the five @use from the section “Partials and @use”. The project file now has the entry <PackageReference Include="AspNetCore.SassCompiler" Version="1.107.1" />.

After dotnet build, the files kladde.css and kladde.css.map are in wwwroot/css. The generated CSS isn’t character for character the same as your old one: The comments with // are missing, and the rules of the wide layout now stand at their base rules instead of at the end. For the browser, that’s equivalent, because every media query rule still comes after its base rule and has the same specificity. For this lesson, all four pages were compared at 360 and 1280 pixels, light and dark, with both stylesheets, by the computed values of every single element: No value differed.

Two errors are common when splitting:

  • The h1 is too small on the screen. The rule from the old “Wider screens” block got lost instead of moving to the base rule.
  • A spacing is wrong now. A rule like .site-footer p landed in another building block while nesting and is now called something like .card .site-footer p. The DevTools show in Styles that the rule doesn’t apply, and through the source map where it comes from.

Exercise 3: Making the build fail

Introduce two errors one after the other, and undo each before you introduce the next:

  1. In _tables.scss, delete the semicolon after width: 100%.
  2. In _tables.scss, insert into the rule for table an @include tokens.wide { font-size: 1.125rem; }, without a @use at the start of the file.

Build each time with dotnet build and read the exit code ($LASTEXITCODE in PowerShell, $? in bash). Write down file, line and cause from the message. Fix the second error last in such a way that the font of the tables really gets larger on wide screens, and then take it out again: The Kladde doesn’t need it.

Solution 3

  1. The build fails with exit code 1 and Error: expected ";" at Styles\_tables.scss 5:18. Line 5 is reported, the line with border-collapse, because only there does Dart Sass notice that the previous declaration hadn’t ended.
  2. The build fails with exit code 1 and There is no module with namespace "tokens". The message shows the lines of the @include. The fix is a line @use "tokens"; at the start of _tables.scss. After that, the build ends with 0, and in the generated CSS there’s a @media (min-width: 48rem) { table { font-size: 1.125rem; } } after the rule for table.

In both cases, no new CSS was created. So the error shows up in the build, not only when a person opens the page. And because the build runs in every pull request, a broken stylesheet doesn’t even reach the main branch.

Exercise 4: Debug and Release

Build the Kladde once with dotnet build and once with dotnet build -c Release. After each build, compare the size of wwwroot/css/kladde.css and look into the file. What changed, and is the source map still there?

Solution 4

With the stylesheet from solution 2:

Debug    kladde.css  4624 bytes, easy to read, plus kladde.css.map
Release  kladde.css  3791 bytes, one single long line, without kladde.css.map

Your numbers differ if your stylesheet is different. Without spaces and line breaks, the file is about a fifth smaller. For a single file of this size, that’s little; how much compression saves in addition during transfer, you’ll measure in lesson 2.1.8. A Release build doesn’t create the source map anew. An old one from an earlier Debug build stays around as long as you don’t empty the folder, though; in any case, the served file in Release no longer points to it.

The Kladde: Kladde.Web with SCSS in the build

Before the commit, check with git status what’s added: Kladde.slnx, .gitignore, in the project Kladde.Web.csproj, Program.cs, appsettings.json, appsettings.Development.json, Properties/launchSettings.json, sasscompiler.json, the six files in Styles and the moved files in wwwroot, plus the disappearance of prototype/. bin/, obj/ and wwwroot/css/ must not be among them. Run check.ps1, then commit with a message like “Serve the prototype from Kladde.Web and build its SCSS” and open a pull request with the screenshots before and after.

The booking form now submits to /book in Kladde.Web, and there’s no endpoint for it yet: The answer is 404. You no longer need the form server for the Kladde from now on. In module 2.5, the booking gets its real page and its real endpoint.

What you can do now

  • You explain what SCSS is and why the generated CSS doesn’t belong in the repository.
  • You use custom properties for everything that applies at runtime and Sass variables for what custom properties can’t do, such as the width in a media query.
  • You nest inside a building block, at most three levels deep, and set & correctly.
  • You split a stylesheet into partials with @use and bundle media queries in a mixin.
  • You create an empty web project that serves wwwroot, and have SCSS compiled on every dotnet build.
  • You read an error message from Dart Sass and find the file and line.

Read more