JOUR 73361

Coding the News

Learn how America's top news organizations escape rigid publishing systems to design beautiful data-driven stories on deadline.

Ben Welsh, Adjunct Assistant Professor

Spring 2026

Mondays 6–9 p.m.

Lab 436

Week 6

Design Systems

How to build expressive pages using a disciplined visual grammar

March 9, 2026

Part 1: Introduction to design systems

Every webpage is controlled by a set of invisible rules. The fonts, the spacing, the colors, the layout. All of it is dictated by the computer code that governs how browsers render its design.

In the static-site frameworks that newsrooms use to create custom pages, these rules are typically codified in a set of common files used across every project. Much like the components covered in previous classes, they offer a prêt-à-porter visual language that developers can use to build pages, relieving them of the need to continually reconsider, and recode, the fundamental design.

These base rules are more than just convenience and constraint, they are a foundation to build on. With all of the basic design decisions taken care of, page makers can focus on the creative work of telling the story, which may sometimes require breaking the rules in thoughtful, deliberate ways.

A great example of a mature design system is the component library published by the Reuters Graphics team, introduced in Week 3.

Reuters Graphics Components
https://reuters-graphics.github.io/graphics-components/

Every component in the library draws from the same set of shared variables, which call for the same typeface, the same spacing scale, the same palette. Change one variable and the entire system updates for every story going forward. This kind of library is sometimes called a design system, or a visual vocabulary.

You can see it in action in "Maps and charts of the Iran crisis, a page the department published in the days after the US and Israel launched an attack. The design system provides all the fundamentals, allowing the journalists to focus on a tight deadline.

Mapping the crisis in Iran
https://www.reuters.com/graphics/IRAN-CRISIS/MAPS/znpnmelervl/

While the specifics vary, virtually every developed digital newsroom has a system like this. Today you'll cover how Svelte implements design systems by taking a tour of the class template, then break out to build something new.

Create your repository

Open your browser and navigate to our template repository at github.com/palewire/cuny-jour-static-site-template.

cuny-jour-static-site-template
https://github.com/palewire/cuny-jour-static-site-template

Click the green "Use this template" button. Name your new repository my-first-design-system. Make sure "Public" is selected, then click "Create repository."

Clone it, open it in Visual Studio Code, and run npm install in the terminal. Start the development server with npm run dev and open your browser to localhost:5173 so you can see the page as you work.

Part 2: Touring the template's design system

The template is styled to look like CUNY's NYCity News Service. It has a blue gradient header, serif headlines, sans-serif body text, and a particular set of colors and spacing. None of that happened by accident. It's all governed by a set of files working together.

Open src/app.scss in your editor. This is the file that governs the entire site. Everything else builds on top of it.

The :root block is where all the CSS custom properties live. You know them by their -- prefix. Because they are defined in the :root selector, they are global variables that can be reused anywhere in the CSS. Defining them in one place creates a single source of truth for the site's visual language. If you want to change the accent color, you change --color-accent here and it updates everywhere.

:root {
  /* NYCity News Service Colors */
  --color-accent: #0033A1;        /* CUNY Blue (PMS 286) */
  --color-dark: #1a1a1a;
  --color-text: #333333;
  --color-white: #ffffff;
  --color-light-gray: #f5f5f5;
  --color-medium-gray: #666666;
  --color-border: #e0e0e0;
  --color-shadow: rgba(0, 0, 0, 0.1);

  /* CUNY Blue Gradient Colors */
  --color-cuny-blue-dark: #002266;
  --color-cuny-blue-light: #0066CC;

  /* Typography */
  --font-serif: 'PT Serif', Georgia, 'Times New Roman', Times, serif;
  --font-sans: 'Droid Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
    Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;

  /* Font Sizes */
  --font-size-xs: 0.75rem;    /* 12px */
  --font-size-sm: 0.875rem;   /* 14px */
  --font-size-base: 1rem;     /* 16px */
  --font-size-lg: 1.125rem;   /* 18px */
  --font-size-xl: 1.25rem;    /* 20px */

  /* Line Heights */
  --leading-tight: 1.15;
  --leading-snug: 1.3;
  --leading-normal: 1.6;
  --leading-relaxed: 1.75;

  /* Spacing */
  --spacing-xs: 0.5rem;   /* 8px */
  --spacing-sm: 1rem;     /* 16px */
  --spacing-md: 1.5rem;   /* 24px */
  --spacing-lg: 2rem;     /* 32px */
  --spacing-xl: 3rem;     /* 48px */
  --spacing-xxl: 4rem;    /* 64px */

  /* Layout */
  --max-width: 800px;
  --max-width-wide: 1200px;

  /* Border Widths */
  --border-width-thin: 1px;
  --border-width-accent: 4px;
}

Below the :root block, the rest of app.scss applies these variables to base HTML elements — body text, headings, links, images and a .container utility class. Scroll through and notice how nearly every value references a variable rather than hardcoding a number.

A root variable can be used by calling the var() function, like var(--color-text). You can see it in action in the body rule, which defines the base typography and colors for the entire site.

body {
  font-family: var(--font-sans);
  font-size: 1rem;
  line-height: var(--leading-normal);
  color: var(--color-text);
  background-color: var(--color-white);
}

Try making some changes to the variables and see how they affect the page. Change --color-text to #ff0000 and watch all the body text turn red.

localhost:5173
The template site with all body text turned red after changing the --color-text CSS variable to #ff0000

Then change back to the original value of #333333 before moving on.

Part 3: Expanding the system

Our template's design system is solid, but it's not perfect. Let's find the weak spots and fix them together. This is a normal part of working with any codebase. Design systems evolve over time as you need new features or discover gaps.

Look back at app.scss. The font-size variables stop at --font-size-xl.

/* Font Sizes */
--font-size-xs: 0.75rem;    /* 12px */
--font-size-sm: 0.875rem;   /* 14px */
--font-size-base: 1rem;     /* 16px */
--font-size-lg: 1.125rem;   /* 18px */
--font-size-xl: 1.25rem;    /* 20px */

But scroll down to the heading styles:

h1 {
  font-size: 2.75rem;
  font-weight: 400;
}

h2 {
  font-size: 1.75rem;
}

h3 {
  font-size: 1.5rem;
}

These sizes — 2.75rem, 1.75rem, 1.5rem — are hardcoded. They don't reference any variable. That means if you wanted to use the same size as an h1 somewhere else, you'd have to look up that it's 2.75rem. And if you wanted to change the headline size across the entire site, you'd have to find every place it appears.

Let's fix this by extending the type scale. Replace the font-size section of your :root block with:

/* Font Sizes */
--font-size-xs: 0.75rem;    /* 12px — footnotes, labels */
--font-size-sm: 0.875rem;   /* 14px — captions, metadata */
--font-size-base: 1rem;     /* 16px — body text */
--font-size-lg: 1.125rem;   /* 18px — lead paragraphs */
--font-size-xl: 1.25rem;    /* 20px — subheadings */
--font-size-2xl: 1.5rem;    /* 24px — section headers (h3) */
--font-size-3xl: 1.75rem;   /* 28px — article subheads (h2) */
--font-size-4xl: 2.75rem;   /* 44px — article headlines (h1) */

Now update the heading rules to use them:

h1 {
  font-size: var(--font-size-4xl);
  font-weight: 400;
}

h2 {
  font-size: var(--font-size-3xl);
}

h3 {
  font-size: var(--font-size-2xl);
}

Save and check your browser. The page should look exactly the same. We haven't changed any values, just moved them into variables. But now the system is more complete.

Now open src/lib/components/BigNumber.svelte. Look at the styles:

<style>
  .big-number {
    text-align: center;
    padding: 1.5rem;
    background-color: var(--color-accent);
    color: white;
    border-radius: 8px;
  }

  .number {
    display: block;
    font-size: 3rem;
    font-weight: bold;
    color: var(--color-white);
    line-height: 1.2;
  }

  .label {
    display: block;
    font-size: 1rem;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    margin-top: 0.5rem;
  }

  .footnote {
    display: block;
    font-size: 0.75rem;
    color: var(--color-white);
    margin-top: 0.25rem;
    font-style: italic;
  }
</style>

This component uses some CSS variables, but also has raw numbers everywhere else: 1.5rem, 3rem, 1rem, 0.5rem, 0.75rem, 0.25rem, 8px. These are the fingerprints of a component that was written quickly and never brought into line with the system.

Let's fix it:

<style>
  .big-number {
    text-align: center;
    padding: var(--spacing-md);
    background-color: var(--color-accent);
    color: var(--color-white);
    border-radius: 8px;
  }

  .number {
    display: block;
    font-size: var(--font-size-4xl);
    font-weight: bold;
    color: var(--color-white);
    line-height: var(--leading-tight);
  }

  .label {
    display: block;
    font-size: var(--font-size-base);
    text-transform: uppercase;
    letter-spacing: 0.05em;
    margin-top: var(--spacing-xs);
  }

  .footnote {
    display: block;
    font-size: var(--font-size-sm);
    color: var(--color-white);
    margin-top: var(--spacing-xs);
    font-style: italic;
  }
</style>

Now do the same for Dashboard.svelte:

<style>
  .row {
    margin: var(--spacing-lg) 0;
    display: flex;
    gap: var(--spacing-sm);
    flex-wrap: wrap;
    justify-content: center;
  }

  .row > :global(*) {
    flex: 1;
    min-width: 200px;
    max-width: 300px;
  }
</style>

There's something new here: :global(*). By default, Svelte scopes all CSS to the component it's written in, so styles in Dashboard.svelte can't affect elements inside BigNumber.svelte, even if BigNumber is a child in the markup.

This is usually what you want, because it prevents styles from leaking across components. But Dashboard's whole job as a parent component is to arrange its children. The .row > * selector needs to reach into the BigNumber cards and set their flex behavior. The :global() wrapper tells Svelte to skip the scoping for that specific selector, allowing it to style child components.

Save both files. BigNumber and Dashboard aren't actually on the page yet. You've been editing the component files, but the template's +page.svelte doesn't use them. Let's add them so you can see your changes.

Open src/routes/+page.svelte and add the imports at the top of the script block:

import BigNumber from '$lib/components/BigNumber.svelte';
import Dashboard from '$lib/components/Dashboard.svelte';

Now add a Dashboard after Image component, using the same CUNY statistics from Week 3:

<Dashboard>
  <BigNumber
    number="2006"
    label="Year Founded"
  />
  <BigNumber
    number="1,300"
    label="Alumni"
  />
  <BigNumber
    number="50%"
    label="Attend tuition free"
    footnote="As of Aug. 2025"
  />
</Dashboard>

Save and check your browser. You should see three blue stat cards in a row.

localhost:5174
The template site showing three blue BigNumber stat cards in a row displaying year founded, alumni count, and tuition percentage

Every value — the padding, font sizes, spacing — is coming from the design system variables you just wired up. This is the discipline at the core of a good design system. When every value comes from the shared vocabulary, your sites look consistent, and you can make sweeping changes by updating a single variable.

Responsive design with Sass mixins

Now drag the viewport of your browser to a narrow phone width. Notice that the BigNumber cards look oversized — the 2.75rem number that felt right on a laptop overwhelms a small screen. Meanwhile, look at the headline at the top of the page. It gets smaller on phones. How does the template do that?

Open app.scss and scroll to the bottom. You'll see something that doesn't look like normal CSS:

@include mobile {
  html {
    font-size: 15px;
  }
  h1 {
    font-size: 1.75rem;
  }

  h2 {
    font-size: 1.5rem;
  }

  h3 {
    font-size: 1.25rem;
  }
}

What is @include mobile? This comes from Sass, a popular extension of CSS that adds features like reusable code blocks, additional variable types and file imports. Sass files get compiled into plain CSS before the browser sees them, so everything we write still becomes regular CSS in the end. The .scss extension on app.scss tells the compiler to process the file first.

The @include mobile syntax calls a mixin — a reusable block of CSS defined somewhere else. To find where it's defined, look at the very first line of app.scss:

@use '$lib/styles' as *;

The @use directive imports code from another file. This one brings in everything from our $lib/styles folder. Open that folder in your Explorer — it's at src/lib/styles/. You'll find three files.

The _index.scss file is the entry point. It uses Sass's @forward directive to bundle other files together:

@forward 'variables';
@forward 'mixins';

The underscore prefix is a Sass convention meaning this is a "partial" file that is meant to be imported into other files. Sass files are often broken into separate partials for organization.

The _variables.scss file contains Sass variables with a $ prefix:

$breakpoint-sm: 768px;
$breakpoint-lg: 1200px;

Why $ variables instead of the -- CSS custom properties we just learned? Because CSS custom properties have a limitation: they can't be used inside @media query conditions that are used to apply different styles at different screen widths.

You can't write @media (max-width: var(--breakpoint)). The browser won't accept it. So for breakpoints, you use Sass variables, which get resolved at compile time before the browser ever sees them.

The _mixins.scss file is where @include mobile comes from. It defines three functions you can use in your styles.

@mixin mobile {
  @media (max-width: $breakpoint-sm) {
    @content;
  }
}

@mixin tablet {
  @media (min-width: $breakpoint-sm) {
    @content;
  }
}

@mixin desktop {
  @media (min-width: $breakpoint-lg) {
    @content;
  }
}

Each mixin wraps a media query. Instead of writing @media (max-width: 768px) { ... } every time you want mobile styles, you write @include mobile { ... }. It's more readable, and if you ever change the breakpoint value, you only change it in one place.

So when the compiler sees this in app.scss:

@include mobile {
  html {
    font-size: 15px;
  }
  h1 {
    font-size: 1.75rem;
  }

  h2 {
    font-size: 1.5rem;
  }

  h3 {
    font-size: 1.25rem;
  }
}

It produces this plain CSS:

@media (max-width: 768px) {
  html {
    font-size: 15px;
  }
  h1 {
    font-size: 1.75rem;
  }

  h2 {
    font-size: 1.5rem;
  }

  h3 {
    font-size: 1.25rem;
  }
}

Now let's apply this pattern to your BigNumber component. We want the big number fonts to be smaller on phones and full-sized on wider screens.

First, change the <style> tag to use Sass and import the mixins:

<style lang="scss">
  @use '../styles' as *;

Now set the font sizes to something that scales down on mobile:

  .footnote {
    display: block;
    font-size: var(--font-size-sm);
    color: var(--color-white);
    margin-top: var(--spacing-xs);
    font-style: italic;
  }


  @include mobile {
    .big-number {
      padding: var(--spacing-sm);
    }

    .number {
      font-size: var(--font-size-3xl);
    }

    .label {
      margin-top: var(--spacing-xs);
      font-size: var(--font-size-sm);
    }

    .footnote {
      margin-top: var(--spacing-xs);
      font-size: var(--font-size-xs);
    }
  }
</style>

Save and test it. Toggle between phone and tablet widths. The numbers should shrink on narrow screens and grow on wider ones.

Mobile view of the template site showing BigNumber stat cards stacked vertically with smaller font sizes from the responsive media query

Part 4: Breaking out of the box

Everything you've done so far has preserved the template's NYCity News Service look. But sometimes a story needs its own signature identity. A feature about the journalism school's impact shouldn't look exactly like a daily news brief about a city council vote. The best newsrooms solve this by overriding their system's defaults at the page level, keeping the underlying structure but swapping out colors, fonts and layout to match the story's tone.

Let's do that now with the page you already have. You'll transform the template's default CUNY article into something that feels more like a bolder feature story, while still using the same architecture underneath.

CSS custom properties can be overridden at any level of the page. If you redefine a variable inside a specific element, that new value applies to everything inside it without changing the global default.

You'll use this to create a story-specific color scheme. Add a <style> block at the bottom of your +page.svelte.

<style lang="scss">
  @use '$lib/styles' as *;

  :global(.story-theme) {
    --color-accent: #fe8807;
    --color-border: #fe8807;
  }
</style>

We're using :global() again here — same idea as in the Dashboard component. Svelte's style scoping works by adding a unique hash to your class names at compile time. Without :global(), Svelte would transform .story-theme into something like .story-theme.svelte-abc123, which wouldn't match the plain .story-theme class in your markup. Wrapping it in :global() tells Svelte to leave the selector alone.

Now add that class to the existing container div in your markup.

<div class="container story-theme">

Save and check your browser. The BigNumber cards have turned orange without any changes to the components themselves.

localhost:5173
The template site with orange BigNumber stat cards and orange accent colors after applying story-theme CSS variable overrides

Because you wired them up to use var(--color-accent) in Part 3, and your .story-theme overrides --color-accent to #fe8807, the new color cascades through automatically. The accent border on the article metadata has changed too. So have the link hover colors. One class, two variable overrides, and the page's personality has completely shifted.

But the site header and footer — which sit outside the .container tag in the layout — still use the original CUNY Blue. The broader system holds, even as you customize within it.

Designing a splashy header

Since you want to go big at the top of the page, let's get that header bar out of the way. Open src/routes/+page.js. You'll see a load function that returns settings for the page.

export function load() {
  return {
    showHeader: true,
    showFooter: true,
  };
}

This is a function that will run on the server before the page loads. It returns an object with two properties: showHeader and showFooter. These are used by the +layout.svelte file to determine whether to render the header and footer components. By default, both are set to true, which is why you see the blue header bar and the footer on the page.

Change showHeader to false.

export function load() {
  return {
    showHeader: false,
    showFooter: true,
  };
}

Save and check your browser. The blue gradient bar is gone. The page feels open and clean.

localhost:5173
The template site with the blue navigation header removed, showing the page starting directly with the headline

The template's ArticleHeader component is designed for standard news articles. For a feature story, you want something completely different: centered, generous with whitespace, and anchored by a headline that dominates the page. You'll place the site's name at the top, centered, followed by the headline and supporting text.

Let's start by loading a display font that will make your headline feel like the cover of a magazine. Your page already has a <svelte:head> block with a title and meta description. Add a font link inside it:

<svelte:head>
  <title>{headline} | NYCity News Service</title>
  <meta name="description" content="At the Craig Newmark Graduate School of Journalism..." />
  <link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700;900&display=swap" rel="stylesheet">
</svelte:head>

This loads Playfair Display from Google Fonts. Nothing uses it yet. You'll put it to work in the next step.

Now let's update the script block at the top of the page. You can remove the ArticleHeader import, since you won't be using that component anymore, and make a few tweaks to the article metadata.

<script>
  // Import all the news furniture components
  import ArticleBody from '$lib/components/ArticleBody.svelte';
  import Image from '$lib/components/Image.svelte';
  import RelatedLinks from '$lib/components/RelatedLinks.svelte';
  import BigNumber from '$lib/components/BigNumber.svelte';
  import Dashboard from '$lib/components/Dashboard.svelte';

  // Article metadata
  let kicker = 'NYCity News Service';
  let headline = 'Become a Force for Good';
  let deck = "At CUNY's journalism school, change is in our DNA";
  let pubDate = 'March 9, 2026';

  // Related stories
  const relatedStories = [
    { headline: "How America's top news organizations escape rigid publishing systems to design beautiful data-driven stories on deadline.", href: 'https://palewi.re/docs/coding-the-news/' },
    { headline: 'How to install, configure and use Visual Studio Code, GitHub and Copilot', href: 'https://palewi.re/docs/coding-the-news/scripts/week-1/' },
    { headline: "How to publish a website with Node.JS and GitHub Actions", href: "https://palewi.re/docs/coding-the-news/scripts/week-2/"},
  ];
</script>

Now remove the <ArticleHeader ... /> tag from your markup and add this custom header block in its place:

<!-- Your page content goes here -->
<div class="container story-theme">

  <div class="story-header">
    <span class="kicker">{kicker}</span>
    <h1>{headline}</h1>
    <hr class="rule" />
    <p class="deck">{deck}</p>
    <p class="byline">{pubDate}</p>
  </div>

  <!-- Lead Image: Animated gif of students at the journalism school -->
  <Image
    src="/example-photo.gif"
    alt="The Craig Newmark Graduate School of Journalism is at 219 West 40th Street in Midtown Manhattan."
    caption="The Craig Newmark Graduate School of Journalism is at 219 West 40th Street in Midtown Manhattan."
    credit="Craig Newmark Graduate School of Journalism"
  />

Now update the styles at the bottom of the +page.svelte file.

<style lang="scss">
  @use '$lib/styles' as *;

  :global(.story-theme) {
    --color-accent: #fe8807;
    --color-border: #fe8807;

    .story-header {
      text-align: center;
      padding: var(--spacing-xxl) var(--spacing-md);
    }

    .kicker {
      display: block;
      font-family: var(--font-sans);
      font-size: var(--font-size-xs);
      text-transform: uppercase;
      letter-spacing: 0.15em;
      color: var(--color-accent);
      margin-bottom: var(--spacing-sm);
    }

    .story-header h1 {
      font-family: 'Playfair Display', Georgia, serif;
      font-size: var(--font-size-4xl);
      font-weight: 900;
      line-height: var(--leading-tight);
      color: var(--color-dark);
      margin-bottom: var(--spacing-sm);
    }

    .rule {
      border: none;
      border-top: var(--border-width-accent) solid var(--color-accent);
      width: 60px;
      margin: 0 auto var(--spacing-sm);
    }

    .deck {
      font-family: var(--font-serif);
      font-size: var(--font-size-lg);
      line-height: var(--leading-normal);
      color: var(--color-text);
      max-width: 480px;
      margin: 0 auto var(--spacing-sm);
    }

    .pubdate {
      font-family: var(--font-sans);
      font-size: var(--font-size-sm);
      text-transform: uppercase;
      letter-spacing: 0.05em;
      color: var(--color-medium-gray);
    }

    @include mobile {
      .story-header h1 {
        font-size: var(--font-size-3xl);
      }

      .deck {
        font-size: var(--font-size-lg);
      }
    }
}
</style>

Save and check your browser. The page should look dramatically different from the standard template.

localhost:5173
The fully customized page with a centered splash header, display font headline, and orange accent theme replacing the standard template design

The whole page has been reshaped but the underlying architecture is the same. The body text, the Image component and the RelatedLinks section all still draw from the same variables.

Homework

Task 1: Create a SplashHeader component

Take the header you built in class and move it out of +page.svelte into a reusable component at src/lib/components/SplashHeader.svelte.

Your component should accept properties for the parts that would change from story to story: the kicker, headline, deck, and publication date. The decorative rule and all the styling should be built into the component itself.

Once it's working, your +page.svelte should be able to use it like this:

<SplashHeader
  kicker="NYCity News Service"
  headline="Become a Force for Good"
  deck="At CUNY's journalism school, change is in our DNA"
  pubDate="March 6, 2026"
/>

A challenge: try to do this by hand rather than asking a code assistant to do it for you. Practicing this without AI will strengthen the skill for the times when you need to understand what your assistant is generating.

Send me a link to the finished component on GitHub.

Task 2: Design your own theme

Starting from a fresh clone of the template, create your own story-specific design for the CUNY article. Override the template's default colors and fonts to create a unique visual identity. Use your new SplashHeader component. Include a Dashboard with BigNumber statistics. Every CSS value in your page styles should reference a variable from the design system. No hardcoded colors, sizes or spacing.

Deploy it to GitHub Pages and send me the link.

Task 3: Find a story that breaks the rules

Browse the websites of news organizations you admire and find a story page that looks different from the publication's standard article template. It could be a feature with a dramatic header, a data project with its own color palette, an interactive page with custom typography.

Come to class ready to share the link and explain what the designers changed from the publication's default look to make the page fit the story.