HomeLab Pulse Status Design System

Build an accessible service-status design system and deploy Storybook to a Pi.

Introduction

30 Second Summary

A home lab can look healthy until one quiet failure disrupts the services your household relies on. Status screens become harder to trust when color carries the whole message.

In this project, you will build an accessible design system for service states with React and Storybook. You will expose a color-only failure before serving the corrected static workshop from a Raspberry Pi on your local network.

What You'll Build

You will open HomeLab Pulse from another device on your network to see three readable service states inside a responsive dashboard.

By the end of this project, you'll have:

  • An isolated component workshop where you can inspect operational, degraded, and offline states independently.
  • An accessibility case study that demonstrates why a color-only status design fails before showing the tested remediation.
  • A responsive LAN demonstration that reflows into one column at 320 CSS pixels while preserving a visible keyboard route to incident details.
  • Secret Mission: Add a high-contrast dashboard theme without changing the status-card markup.

Are there any prerequisites?

You need a Mac with Node.js, npm, and a modern browser.

Your Raspberry Pi 5 must run Raspberry Pi OS and accept SSH connections from the Mac over your local network.

Before We Start

A clear product story keeps every accessibility decision tied to the evidence you want to show. Before the hands-on work begins, commit to what HomeLab Pulse demonstrates and why that matters for UI engineering or design-system roles.

Set Up the Storybook Workshop

Your portfolio story now has a clear purpose. A reproducible Storybook workshop gives each service state an isolated place to render.

You will verify the local runtime first. You will then assemble a pinned React workspace that displays its first service card.

In this step, get ready to:
  • Verify that Node.js meets the supported runtime requirement.
  • Create the workshop files with pinned dependencies.
  • Launch Storybook and inspect the starter status card.
Verify Node.js and npm

Node.js runs the workshop tools on your Mac. The installed version must be at least 22.12. The installed npm version must be at least 10.

  • Press Cmd+Space to open macOS search.
  • Type Terminal into the search bar.
  • Press Enter to open macOS Terminal.

Before you run the check, consider whether both tools are likely to meet the required versions.

  • Check both installed versions by running these commands:
node --version
npm --version

What Do These Commands Check?

  • The first command prints the installed Node.js version.
  • The second command prints the installed npm version.
  • Node.js v24.22.1 LTS is the recommended version when you need an upgrade.

✔️ Both versions meet the minimums

Your local runtime is ready. Node.js can run the pinned Vite and Storybook dependencies.

ⓧ I see an older version

An older runtime can reject the pinned dependencies before the workshop starts. Install Node.js v24.22.1 LTS to bring Node.js and npm onto the supported path.

  • Open the official Node.js download page.
  • Download the macOS installer for Node.js v24.22.1 LTS.
  • Run the downloaded installer.
  • Complete the installer prompts.
  • Quit macOS Terminal.
  • Press Cmd+Space to reopen macOS search.
  • Type Terminal into the search bar.
  • Press Enter to reopen macOS Terminal.
  • Check the upgraded versions by running these commands:
node --version
npm --version

What Should You See?

Node.js should now report v24.22.1. npm should report version 10 or newer.

ⓧ Command not found

A missing command means Node.js is unavailable from your current Terminal session. The official macOS installer adds the runtime plus npm.

  • Open the official Node.js download page.
  • Download the macOS installer for Node.js v24.22.1 LTS.
  • Run the downloaded installer.
  • Complete the installer prompts.
  • Quit macOS Terminal.
  • Press Cmd+Space to reopen macOS search.
  • Type Terminal into the search bar.
  • Press Enter to reopen macOS Terminal.
  • Confirm that both commands are now available by running:
node --version
npm --version

What Should You See?

Terminal should now print one version for Node.js. It should print a second version for npm.

Assemble the workshop files

A manual configuration keeps every workshop file visible. You control the exact dependency versions without depending on scaffold output that can change between releases.

Why Storybook Instead of a Vite-Only Page?

Vite provides the build tooling. Storybook adds isolated component stories plus accessibility checks without requiring you to build a custom state switcher.

  • Create the workshop folders on your Desktop by running these commands:
cd ~/Desktop
mkdir homelab-pulse
cd homelab-pulse
mkdir .storybook src
ls -a

What Does This Set Up?

  • The first command places the project in a predictable location on your Desktop.
  • The next commands create homelab-pulse plus its .storybook and src directories.
  • The final command includes hidden entries so you can confirm that .storybook exists.

Terminal now lists .storybook and src. Your project has a visible file structure to build on.

Could Not Create the Folders?

Confirm that Terminal is allowed to access your Desktop. If homelab-pulse already exists, inspect that folder before replacing any files.

Use this prompt for targeted help: Help me create the homelab-pulse folders on my macOS Desktop.

The pinned manifest makes every installation reproducible. It records the exact React, Vite, Storybook, and accessibility-addon versions used by the workshop.

  • Open package.json in Neovim by running this command:
nvim package.json

What Does This Command Open?

Neovim opens a new buffer for package.json. Saving the buffer creates the manifest inside homelab-pulse.

  • Press i to enter Insert mode.
  • Paste the pinned manifest into package.json from the first line:
{
  "name": "homelab-pulse",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "storybook": "storybook dev",
    "build-storybook": "storybook build"
  },
  "dependencies": {
    "react": "19.3.1",
    "react-dom": "19.3.1"
  },
  "devDependencies": {
    "@storybook/addon-a11y": "10.6.3",
    "@storybook/react-vite": "10.6.3",
    "@vitejs/plugin-react": "6.1.2",
    "storybook": "10.6.3",
    "vite": "8.3.5"
  }
}

What Does This Manifest Control?

  • The scripts object provides commands for running the workshop plus building its static output.
  • The dependencies object pins the browser-facing React packages.
  • The devDependencies object pins Vite, Storybook, the React integration, and the accessibility addon.
  • Press Esc to leave Insert mode.
  • Type :wq to save the manifest.
  • Press Enter to return to Terminal.
  • Install the pinned dependencies by running:
npm install

What Does This Installation Create?

npm reads the pinned versions from package.json. It installs those packages locally and generates package-lock.json for repeatable installations.

The first installation can take a little time while npm downloads the packages. Terminal returns to its prompt when the dependency installation is complete.

Did the Installation Fail?

Confirm that Terminal is inside homelab-pulse. Check that package.json begins with an opening brace and ends with a closing brace.

Use this prompt to inspect the output: Help me diagnose my npm install failure.

Vite connects React to the local build process. Storybook then uses that integration to render each component story.

  • Open vite.config.js plus .storybook/main.js in Neovim by running:
nvim vite.config.js .storybook/main.js

How Are These Files Opened?

Neovim opens both file paths as buffers. The first buffer is vite.config.js.

  • Press i to enter Insert mode.
  • Paste the Vite configuration into vite.config.js.
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
});

What Does the Vite Configuration Do?

  • The imports load Vite's configuration helper plus its React plugin.
  • The exported configuration activates React support for the workshop.
  • Press Esc to leave Insert mode.
  • Type :w to save vite.config.js.
  • Press Enter to complete the save.
  • Type :next to switch to .storybook/main.js.
  • Press Enter to open the next buffer.
  • Press i to enter Insert mode.
  • Paste the Storybook configuration into .storybook/main.js.
export default {
  stories: ['../src/**/*.stories.@(js|jsx)'],
  addons: ['@storybook/addon-a11y'],
  framework: {
    name: '@storybook/react-vite',
    options: {},
  },
};

What Does the Storybook Configuration Do?

  • The stories pattern discovers JavaScript and JSX story files inside src.
  • The addons entry enables automated accessibility checks.
  • The framework object connects Storybook to React through Vite.
  • Press Esc to leave Insert mode.
  • Type :wq to save .storybook/main.js.
  • Press Enter to return to Terminal.
  • Confirm that both configuration files exist by running:
ls -a .storybook vite.config.js

What Should the File Check Show?

Terminal lists vite.config.js plus main.js inside .storybook.

Is a Configuration File Missing?

Confirm that you saved each Neovim buffer before moving on. Check that main.js is inside .storybook.

Use this prompt for a path check: Help me find my Storybook configuration files.

The preview file loads global styles for every story. The starter stylesheet gives the isolated canvas a consistent page surface.

  • Open .storybook/preview.js plus src/theme.css in Neovim by running:
nvim .storybook/preview.js src/theme.css

Which File Opens First?

Neovim opens .storybook/preview.js first. The stylesheet waits in the next buffer.

  • Press i to enter Insert mode.
  • Paste the preview configuration into .storybook/preview.js.
import '../src/theme.css';

export default {};

What Does the Preview File Do?

The import applies theme.css to every story. The empty preview object leaves room for project-wide settings later.

  • Press Esc to leave Insert mode.
  • Type :w to save .storybook/preview.js.
  • Press Enter to complete the save.
  • Type :next to switch to src/theme.css.
  • Press Enter to open the next buffer.
  • Press i to enter Insert mode.
  • Paste the starter stylesheet into src/theme.css.
:root {
  color: #172033;
  background: #eef2f7;
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  font-synthesis: none;
  text-rendering: optimizeLegibility;
  --page: #eef2f7;
  --surface: #ffffff;
  --surface-muted: #f7f9fc;
  --text: #172033;
  --text-muted: #526078;
  --border: #cbd5e1;
  --focus: #1d4ed8;
}

* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: var(--page); color: var(--text); }
.story-shell { min-height: 100vh; padding: 2rem; background: var(--page); }
.story-shell .status-card { max-width: 30rem; }

What Does the Starter Stylesheet Provide?

  • The root values establish the page colors plus typography shared by every story.
  • The sizing rule makes element dimensions include their padding and borders.
  • The story shell adds space around the isolated card.
  • Press Esc to leave Insert mode.
  • Type :wq to save src/theme.css.
  • Press Enter to return to Terminal.
  • Confirm that the preview files exist by running:
ls -a .storybook src/theme.css

What Should the Preview Check Show?

Terminal lists preview.js inside .storybook. It also lists src/theme.css.

Are the Preview Styles Missing?

Confirm that preview.js sits inside .storybook. Confirm that theme.css sits inside src.

Use this prompt to compare the paths: Help me connect my Storybook preview to theme.css.

A story renders one named component state in isolation. The component receives service details through props while the story supplies the starter values.

  • Open src/StatusCard.jsx in Neovim by running:
nvim src/StatusCard.jsx

What Are You Creating?

This module owns the card markup plus its status mapping. Its exported component becomes available to Storybook stories.

  • Press i to enter Insert mode.
  • Paste the status map plus identifier helper at the top of src/StatusCard.jsx.
const statusContent = {
  operational: { label: 'Operational', symbol: 'OK' },
  degraded: { label: 'Degraded', symbol: '!' },
  offline: { label: 'Offline', symbol: 'X' },
};

function toId(value) {
  return value.toLowerCase().replace(/[^a-z0-9]+/g, '-');
}

What Do These Helpers Hold?

  • The status map connects each state to its visible label plus text symbol.
  • The identifier helper converts a service name into a stable heading identifier.
  • Add the exported component directly below the identifier helper:
export function StatusCard({ name, status, detail, updatedAt }) {
  const currentStatus = statusContent[status] ?? statusContent.offline;
  const titleId = `${toId(name)}-title`;

  return (
    <article className={`status-card status-card--${status}`} aria-labelledby={titleId}>
      <div className="status-card__heading">
        <div>
          <p className="status-card__eyebrow">Service</p>
          <h2 id={titleId}>{name}</h2>
        </div>
        <span className="status-pill">
          <span className="status-pill__symbol" aria-hidden="true">
            {currentStatus.symbol}
          </span>
          <span>{currentStatus.label}</span>
        </span>
      </div>
      <p className="status-card__detail">{detail}</p>
      <p className="status-card__updated">Updated {updatedAt}</p>
    </article>
  );
}

What Does the Component Render?

  • The component selects content for the supplied status.
  • The article connects its accessible name to the service heading.
  • The remaining elements display the service detail plus update time.
  • Press Esc to leave Insert mode.
  • Type :wq to save src/StatusCard.jsx.
  • Press Enter to return to Terminal.
  • Confirm that the component module exists by running:
ls src/StatusCard.jsx

What Should the Component Check Show?

Terminal prints src/StatusCard.jsx. The component module is now stored on disk.

  • Open src/StatusCard.stories.jsx in Neovim by running:
nvim src/StatusCard.stories.jsx

What Does the Story File Add?

The story file gives Storybook a title plus one concrete card state. The title controls where the story appears in the sidebar.

  • Press i to enter Insert mode.
  • Paste the starter story into src/StatusCard.stories.jsx.
import { StatusCard } from './StatusCard';

const meta = {
  title: 'Components/StatusCard',
  component: StatusCard,
  decorators: [
    (Story) => (
      <div className="story-shell">
        <Story />
      </div>
    ),
  ],
};

export default meta;

export const Operational = {
  args: {
    name: 'DNS filtering',
    status: 'operational',
    detail: 'Queries are resolving normally across the home network.',
    updatedAt: '2 minutes ago',
  },
};

What Does the Starter Story Define?

  • The metadata places the component under Components/StatusCard in the sidebar.
  • The decorator wraps the card in the shared story shell.
  • The Operational story supplies predictable service data through args.
  • Press Esc to leave Insert mode.
  • Type :wq to save src/StatusCard.stories.jsx.
  • Press Enter to return to Terminal.
  • Confirm that the component files exist by running:
ls src/StatusCard.jsx src/StatusCard.stories.jsx

What Should the Component Check Show?

Terminal lists both StatusCard.jsx and StatusCard.stories.jsx.

Is a Component File Missing?

Confirm that both filenames use the same capitalization. Check that both files are inside src.

Use this prompt to inspect the file paths: Help me verify my StatusCard files.

✔️ Awesome, I've got everything!

Your pinned manifest plus workshop files are saved. Double-check that package-lock.json also exists after the npm installation.

ⓧ I'd like to double check the full code

Compare each saved file with the complete setup below. The generated package-lock.json should sit beside package.json.

{
  "name": "homelab-pulse",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "storybook": "storybook dev",
    "build-storybook": "storybook build"
  },
  "dependencies": {
    "react": "19.3.1",
    "react-dom": "19.3.1"
  },
  "devDependencies": {
    "@storybook/addon-a11y": "10.6.3",
    "@storybook/react-vite": "10.6.3",
    "@vitejs/plugin-react": "6.1.2",
    "storybook": "10.6.3",
    "vite": "8.3.5"
  }
}
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
});
export default {
  stories: ['../src/**/*.stories.@(js|jsx)'],
  addons: ['@storybook/addon-a11y'],
  framework: {
    name: '@storybook/react-vite',
    options: {},
  },
};
import '../src/theme.css';

export default {};
const statusContent = {
  operational: { label: 'Operational', symbol: 'OK' },
  degraded: { label: 'Degraded', symbol: '!' },
  offline: { label: 'Offline', symbol: 'X' },
};

function toId(value) {
  return value.toLowerCase().replace(/[^a-z0-9]+/g, '-');
}

export function StatusCard({ name, status, detail, updatedAt }) {
  const currentStatus = statusContent[status] ?? statusContent.offline;
  const titleId = `${toId(name)}-title`;

  return (
    <article className={`status-card status-card--${status}`} aria-labelledby={titleId}>
      <div className="status-card__heading">
        <div>
          <p className="status-card__eyebrow">Service</p>
          <h2 id={titleId}>{name}</h2>
        </div>
        <span className="status-pill">
          <span className="status-pill__symbol" aria-hidden="true">
            {currentStatus.symbol}
          </span>
          <span>{currentStatus.label}</span>
        </span>
      </div>
      <p className="status-card__detail">{detail}</p>
      <p className="status-card__updated">Updated {updatedAt}</p>
    </article>
  );
}
import { StatusCard } from './StatusCard';

const meta = {
  title: 'Components/StatusCard',
  component: StatusCard,
  decorators: [
    (Story) => (
      <div className="story-shell">
        <Story />
      </div>
    ),
  ],
};

export default meta;

export const Operational = {
  args: {
    name: 'DNS filtering',
    status: 'operational',
    detail: 'Queries are resolving normally across the home network.',
    updatedAt: '2 minutes ago',
  },
};
:root {
  color: #172033;
  background: #eef2f7;
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  font-synthesis: none;
  text-rendering: optimizeLegibility;
  --page: #eef2f7;
  --surface: #ffffff;
  --surface-muted: #f7f9fc;
  --text: #172033;
  --text-muted: #526078;
  --border: #cbd5e1;
  --focus: #1d4ed8;
}

* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: var(--page); color: var(--text); }
.story-shell { min-height: 100vh; padding: 2rem; background: var(--page); }
.story-shell .status-card { max-width: 30rem; }
Launch the starter story

The development server reads the Storybook configuration plus the discovered story files. It stays attached to Terminal so it can rebuild the workshop when a saved file changes.

Before you start the server, predict whether the sidebar will use the metadata title or the component filename.

  • Start the Storybook development server by running:
npm run storybook

What Does This Command Start?

npm runs the storybook script from package.json. Storybook prints a local URL and keeps the server attached to Terminal.

Press Ctrl+C when you eventually want to stop the development server.

Terminal prints a local Storybook URL. The running process continues watching your project files.

  • Open the local URL printed in Terminal.
  • Select Components in the Storybook sidebar.
  • Select StatusCard in the expanded component group.
  • Select Operational to display the starter story.

You will see a service card for DNS filtering. It includes the operational label plus its service detail and update time.

Does Storybook Fail to Load?

Check Terminal for the first reported filename. A missing quote or closing brace in that file can stop the development server.

Confirm that the story title is Components/StatusCard. Confirm that preview.js imports ../src/theme.css.

Use this prompt to inspect the failure: Help me diagnose my Storybook startup error.

Your component workshop is running with a repeatable dependency set. Next, you will expand the starter into three service states and compare them in isolation.

Test the Color-Only Failure

Your Storybook workshop already renders a starter StatusCard in isolation. This gives you a controlled place to compare service states.

Color-only status cues can look clear under ideal viewing conditions. This step creates a deliberate baseline so you can test how quickly that clarity breaks down.

In this step, get ready to:
  • Create operational, degraded, and offline stories.
  • Distinguish the temporary states through pale surface colors.
  • Capture evidence from the Accessibility panel plus vision simulation.
Build the temporary color-only card

A component state represents one condition that a component can display. The temporary card uses its status prop only to choose a surface class.

  • Return to Neovim from earlier.
  • Select src/StatusCard.jsx in the file explorer.
  • Replace the file contents with this temporary component:
export function StatusCard({ name, status, detail, updatedAt }) {
  // Use the status prop only to choose a temporary surface color.
  return (
    <article className={`status-card status-card--${status}`}>
      <p className="status-card__label">Service status</p>
      <h2>{name}</h2>
      <p className="status-card__detail">{detail}</p>
      <p className="status-card__updated">Updated {updatedAt}</p>
    </article>
  );
}

What Does This Code Do?

  • The status prop becomes part of the card class name. CSS can give each state a different surface.
  • The card displays the service name plus its detail. It does not display the current state as readable text.
  • The generic Service status label gives the accessibility check a temporary text target.
  • Save src/StatusCard.jsx.
  • Switch back to the browser from earlier.
  • Refresh the current StatusCard story.

You should still see the starter service name plus its detail. The card now shows the generic Service status label without naming the current state.

Does the Card Stop Rendering?

Check that src/StatusCard.jsx still exports StatusCard. Confirm that every opening JSX tag has a matching closing tag.

Confirm that the class name uses backticks around status-card status-card--${status}.

Help me fix the temporary StatusCard component.

✔️ Awesome, I've got everything!

Your temporary component now exposes the three state classes without printing a readable state name.

ⓧ I'd like to double check the full code

export function StatusCard({ name, status, detail, updatedAt }) {
  // Use the status prop only to choose a temporary surface color.
  return (
    <article className={`status-card status-card--${status}`}>
      <p className="status-card__label">Service status</p>
      <h2>{name}</h2>
      <p className="status-card__detail">{detail}</p>
      <p className="status-card__updated">Updated {updatedAt}</p>
    </article>
  );
}

Each named story supplies a fixed set of props to the same component. This makes the state comparison repeatable.

  • Return to Neovim.
  • Select src/StatusCard.stories.jsx in the file explorer.
  • Replace the file contents with these three stories:
import { StatusCard } from './StatusCard';

// Register the component under a stable Storybook sidebar path.
const meta = {
  title: 'Components/StatusCard',
  component: StatusCard,
  decorators: [
    (Story) => (
      <div className="story-shell">
        <Story />
      </div>
    ),
  ],
};

export default meta;

// Show the same service in three operational conditions.
export const Operational = {
  args: { name: 'DNS filtering', status: 'operational', detail: 'Queries are resolving normally across the home network.', updatedAt: '2 minutes ago' },
};

export const Degraded = {
  args: { name: 'DNS filtering', status: 'degraded', detail: 'Responses are slower than the expected baseline.', updatedAt: '1 minute ago' },
};

export const Offline = {
  args: { name: 'DNS filtering', status: 'offline', detail: 'The service is not responding to health checks.', updatedAt: 'just now' },
};

How Do the Stories Work?

  • The metadata places the component under Components/StatusCard in the sidebar.
  • Each named export becomes an isolated story. Its args provide the card props.
  • The shared DNS filtering name keeps the comparison focused on service condition.
  • Save src/StatusCard.stories.jsx.
  • Switch back to the Storybook browser tab.
  • Refresh the page.

You should see Operational, Degraded, plus Offline beneath Components/StatusCard in the sidebar.

Are the Three Stories Missing?

Confirm that src/StatusCard.stories.jsx ends with the .stories.jsx filename pattern. Confirm that each story uses a named export.

Check that the import path is exactly ./StatusCard.

Help me find my missing StatusCard stories.

✔️ Awesome, I've got everything!

Great. The sidebar now gives each temporary service condition its own isolated story.

ⓧ I'd like to double check the full code

import { StatusCard } from './StatusCard';

// Register the component under a stable Storybook sidebar path.
const meta = {
  title: 'Components/StatusCard',
  component: StatusCard,
  decorators: [
    (Story) => (
      <div className="story-shell">
        <Story />
      </div>
    ),
  ],
};

export default meta;

// Show the same service in three operational conditions.
export const Operational = {
  args: { name: 'DNS filtering', status: 'operational', detail: 'Queries are resolving normally across the home network.', updatedAt: '2 minutes ago' },
};

export const Degraded = {
  args: { name: 'DNS filtering', status: 'degraded', detail: 'Responses are slower than the expected baseline.', updatedAt: '1 minute ago' },
};

export const Offline = {
  args: { name: 'DNS filtering', status: 'offline', detail: 'The service is not responding to health checks.', updatedAt: 'just now' },
};
Apply the temporary state colors

The stylesheet turns each status class into a pale surface. The generic label stays muted so the automated check can measure its contrast.

  • Return to Neovim.
  • Select src/theme.css in the file explorer.
  • Replace the file contents with these temporary styles:
:root {
  color: #172033;
  background: #eef2f7;
  font-family: Inter, ui-sans-serif, system-ui, sans-serif;
}

* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: #eef2f7; }
.story-shell { min-height: 100vh; padding: 2rem; }
.story-shell .status-card { max-width: 30rem; }

/* Keep card structure consistent while the surface carries the state. */
.status-card {
  padding: 1.25rem;
  border: 1px solid #cbd5e1;
  border-radius: 1rem;
  box-shadow: 0 0.6rem 1.8rem rgb(15 23 42 / 8%);
}

.status-card--operational { background: #d1fae5; }
.status-card--degraded { background: #fef3c7; }
.status-card--offline { background: #fee2e2; }

/* Keep the temporary label muted for the accessibility comparison. */
.status-card__label { margin: 0 0 0.35rem; color: #94a3b8; font-size: 0.75rem; font-weight: 700; }
.status-card h2 { margin: 0; font-size: 1.15rem; }
.status-card__detail { margin: 1.4rem 0 1rem; line-height: 1.55; }
.status-card__updated { margin: 0; color: #526078; font-size: 0.82rem; }

What Do These Styles Change?

  • The three modifier classes apply pale green, amber, plus red surfaces.
  • The component markup provides no visible state name or symbol.
  • The status-card__label selector gives the generic label a muted gray color.
  • Save src/theme.css.
  • Switch back to the Storybook browser tab.
  • Select each StatusCard story in the sidebar.

You should see a pale green Operational card. You should see an amber Degraded card plus a pale red Offline card.

Do the Cards Share One Background?

Inspect the card class in the browser. It should end with status-card--operational, status-card--degraded, or status-card--offline.

Confirm that the status value in each story matches its CSS modifier.

Help me diagnose my temporary state colors.

✔️ Awesome, I've got everything!

The three stories now rely on pale card surfaces to communicate their status.

ⓧ I'd like to double check the full code

:root {
  color: #172033;
  background: #eef2f7;
  font-family: Inter, ui-sans-serif, system-ui, sans-serif;
}

* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: #eef2f7; }
.story-shell { min-height: 100vh; padding: 2rem; }
.story-shell .status-card { max-width: 30rem; }

/* Keep card structure consistent while the surface carries the state. */
.status-card {
  padding: 1.25rem;
  border: 1px solid #cbd5e1;
  border-radius: 1rem;
  box-shadow: 0 0.6rem 1.8rem rgb(15 23 42 / 8%);
}

.status-card--operational { background: #d1fae5; }
.status-card--degraded { background: #fef3c7; }
.status-card--offline { background: #fee2e2; }

/* Keep the temporary label muted for the accessibility comparison. */
.status-card__label { margin: 0 0 0.35rem; color: #94a3b8; font-size: 0.75rem; font-weight: 700; }
.status-card h2 { margin: 0; font-size: 1.15rem; }
.status-card__detail { margin: 1.4rem 0 1rem; line-height: 1.55; }
.status-card__updated { margin: 0; color: #526078; font-size: 0.82rem; }
Test the accessibility failure

Before you open the results, do you think all three stories will pass the automated accessibility check?

  • Select Operational beneath Components/StatusCard.
  • Open the Accessibility panel in the Storybook addons area.
  • Select Violations in the Accessibility panel.

You should see a color-contrast violation tied to the muted temporary label. This is the intended failure in the baseline design.

  • Select Passes in the Accessibility panel.
  • Select Incomplete in the Accessibility panel.
  • Select Degraded in the sidebar.
  • Select Violations in the Accessibility panel.
  • Select Passes in the Accessibility panel.
  • Select Incomplete in the Accessibility panel.
  • Select Offline in the sidebar.
  • Review all three result categories in the Accessibility panel.

The low-contrast finding remains visible across the three stories. The Incomplete results also identify checks that need manual review.

Vision simulation changes how the rendered colors are perceived. It lets you test whether the state remains understandable when hue differences become less reliable.

  • Select the vision-simulation control in the Storybook toolbar.
  • Choose a simulation that reduces color discrimination.
  • Compare the three StatusCard stories under the active simulation.
  • Turn off the simulation after the comparison.

The pale surfaces become unreliable as the only status cue. The cards contain no visible state name or symbol to carry the meaning when color stops helping.

What Does This Test Prove?

Automated checks provide a first line of accessibility testing. A reported pass does not establish complete conformance.

Your manual simulation exposes the larger design problem. Service state needs a cue that survives without color.

You have captured concrete evidence of the color-only failure. Next, you will give every state readable text plus a distinct symbol while preserving the visual system.

Encode Accessible Service States

The three isolated states in Storybook have exposed the weakness in the color-only design. Color still carries all the meaning while the temporary label remains hard to read.

This step fixes that failure with visible labels plus redundant symbols. You will use design tokens to preserve the visual system while raising the contrast.

In this step, get ready to:
  • Encode each service state with a visible label plus a text symbol.
  • Replace the temporary colors with contrast-safe tokens.
  • Verify every story with automated checks plus color-removal testing.
Encode status meaning in the component

Each status needs meaning that survives without its background color. The visible label carries that meaning while the text symbol provides a second visual cue.

  • Switch back to Neovim from earlier.
  • Open src/StatusCard.jsx in the existing Neovim session.
  • Select all of the temporary component code.
  • Start the replacement by pasting this status map:
const statusContent = {
  operational: { label: 'Operational', symbol: 'OK' },
  degraded: { label: 'Degraded', symbol: '!' },
  offline: { label: 'Offline', symbol: 'X' },
};

What Does the Status Map Do?

  • The statusContent object keeps each machine-readable status beside its visible label plus symbol.
  • The labels communicate the full state without relying on color.
  • The symbols give sighted users a second cue when colors look similar.
  • Paste this helper plus component directly below the status map:
function toId(value) {
  return value.toLowerCase().replace(/[^a-z0-9]+/g, '-');
}

export function StatusCard({ name, status, detail, updatedAt }) {
  const currentStatus = statusContent[status] ?? statusContent.offline;
  const titleId = `${toId(name)}-title`;

  return (
    <article className={`status-card status-card--${status}`} aria-labelledby={titleId}>
      <div className="status-card__heading">
        <div>
          <p className="status-card__eyebrow">Service</p>
          <h2 id={titleId}>{name}</h2>
        </div>
        <span className="status-pill">
          <span className="status-pill__symbol" aria-hidden="true">
            {currentStatus.symbol}
          </span>
          <span>{currentStatus.label}</span>
        </span>
      </div>
      <p className="status-card__detail">{detail}</p>
      <p className="status-card__updated">Updated {updatedAt}</p>
    </article>
  );
}

What Does This Component Do?

  • The toId() helper turns the service name into a stable heading identifier.
  • The aria-labelledby attribute gives the article its accessible name through the service heading.
  • The visible currentStatus.label communicates the state to every user.
  • The aria-hidden="true" attribute keeps the redundant symbol out of the accessibility tree.
  • The detail plus timestamp remain ordinary readable text inside the article.
  • Save src/StatusCard.jsx.
  • Switch back to the browser from earlier.
  • Select the Operational story under Components/StatusCard.

You will see the service heading beside an OK symbol plus an Operational label. The readable detail plus updated time remain below the heading.

Don't See the Status Text?

Check that currentStatus reads from statusContent. A mismatched variable name prevents the label plus symbol from rendering.

Confirm that the JSX contains both spans inside status-pill.

Help me debug the accessible status content in my StatusCard component.

✔️ Awesome, I've got everything!

Great. Your component now gives every service state a visible name plus a redundant symbol.

ⓧ I'd like to double check the full code

Compare your saved src/StatusCard.jsx with this complete file.

const statusContent = {
  operational: { label: 'Operational', symbol: 'OK' },
  degraded: { label: 'Degraded', symbol: '!' },
  offline: { label: 'Offline', symbol: 'X' },
};

function toId(value) {
  return value.toLowerCase().replace(/[^a-z0-9]+/g, '-');
}

export function StatusCard({ name, status, detail, updatedAt }) {
  const currentStatus = statusContent[status] ?? statusContent.offline;
  const titleId = `${toId(name)}-title`;

  return (
    <article className={`status-card status-card--${status}`} aria-labelledby={titleId}>
      <div className="status-card__heading">
        <div>
          <p className="status-card__eyebrow">Service</p>
          <h2 id={titleId}>{name}</h2>
        </div>
        <span className="status-pill">
          <span className="status-pill__symbol" aria-hidden="true">
            {currentStatus.symbol}
          </span>
          <span>{currentStatus.label}</span>
        </span>
      </div>
      <p className="status-card__detail">{detail}</p>
      <p className="status-card__updated">Updated {updatedAt}</p>
    </article>
  );
}
Replace color-only styles with tokens

The component now carries accessible meaning. Its stylesheet needs readable surfaces plus state colors that preserve the same operational identity.

  • Switch back to Neovim.
  • Open src/theme.css in the existing Neovim session.
  • Select all of the temporary stylesheet.
  • Replace it with these page tokens plus story foundations:
:root {
  color: #172033;
  background: #eef2f7;
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  font-synthesis: none;
  text-rendering: optimizeLegibility;
  --page: #eef2f7;
  --surface: #ffffff;
  --surface-muted: #f7f9fc;
  --text: #172033;
  --text-muted: #526078;
  --border: #cbd5e1;
  --focus: #1d4ed8;
}

* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: var(--page); color: var(--text); }
.story-shell { min-height: 100vh; padding: 2rem; background: var(--page); }
.story-shell .status-card { max-width: 30rem; }

How Do the Foundations Help?

  • The root CSS custom properties define shared page colors plus text colors.
  • The story-shell class gives each isolated story a consistent canvas.
  • The global box model keeps padding plus borders inside each element's measured width.
  • Save src/theme.css.
  • Switch back to the open Storybook browser tab.

You will see the story on a light blue-gray page with two rems of surrounding space. The text uses the dark root color.

Still See the Temporary Background?

Confirm that the old pale state rules were removed from src/theme.css. A duplicate rule later in the file can override the new page tokens.

Help me find the temporary CSS that is overriding my new theme tokens.

  • Append these card surfaces plus state tokens to src/theme.css.
.status-card {
  --status-bg: #e2e8f0;
  --status-text: #334155;
  --status-symbol-bg: #475569;
  --status-symbol-text: #ffffff;
  padding: 1.25rem;
  border: 1px solid var(--border);
  border-top: 0.3rem solid var(--status-symbol-bg);
  border-radius: 1rem;
  background: var(--surface);
  box-shadow: 0 0.6rem 1.8rem rgb(15 23 42 / 8%);
}

.status-card--operational { --status-bg: #d1fae5; --status-text: #065f46; --status-symbol-bg: #047857; }
.status-card--degraded { --status-bg: #fef3c7; --status-text: #78350f; --status-symbol-bg: #b45309; }
.status-card--offline { --status-bg: #fee2e2; --status-text: #7f1d1d; --status-symbol-bg: #b91c1c; }

How Do the State Tokens Work?

  • The base card uses local properties for the status background plus text colors.
  • Each state modifier changes those properties without changing the component markup.
  • The dark top border gives each card a structural state cue beyond the pale surface color.
  • Save src/theme.css.
  • Switch back to the open Operational story.

You will see a white card with a border plus a dark green top edge. The card now sits above the page with a soft shadow.

Missing the Colored Top Edge?

Check that the card class includes the current status after status-card--. The modifier supplies the value used by the top border.

Confirm that border-top uses var(--status-symbol-bg).

Help me debug the status-specific border on my StatusCard.

  • Append these heading plus status-pill styles to src/theme.css.
.status-card__heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
.status-card__eyebrow, .dashboard__eyebrow, .incident-banner__eyebrow, .incident-details__eyebrow { margin: 0 0 0.35rem; color: var(--text-muted); font-size: 0.75rem; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase; }
.status-card h2, .incident-banner h2, .incident-details h2, .dashboard h1 { margin: 0; line-height: 1.15; }
.status-card h2 { font-size: 1.15rem; }
.status-pill { display: inline-flex; align-items: center; flex: 0 0 auto; gap: 0.45rem; padding: 0.35rem 0.6rem; border-radius: 999px; background: var(--status-bg); color: var(--status-text); font-size: 0.78rem; font-weight: 800; }
.status-pill__symbol { display: grid; width: 1.45rem; height: 1.45rem; place-items: center; border-radius: 50%; background: var(--status-symbol-bg); color: var(--status-symbol-text); font-size: 0.65rem; }
.status-card__detail { min-height: 3rem; margin: 1.4rem 0 1rem; color: var(--text); line-height: 1.55; }
.status-card__updated { margin: 0; color: var(--text-muted); font-size: 0.82rem; }

What Do These Styles Add?

  • The heading layout separates the service name from its status pill.
  • The pill uses the active state's readable background plus text colors.
  • The symbol receives a stronger solid background to remain visually distinct.
  • The detail plus timestamp use separate spacing and readable text colors.
  • Save src/theme.css.
  • Switch back to the open Storybook browser tab.
  • Select the Degraded story.

You will see a dark-on-light Degraded pill beside a solid ! symbol. The detail plus updated time remain readable below it.

Does the Pill Look Unstyled?

Confirm that the JSX uses status-pill plus status-pill__symbol exactly. A class-name mismatch prevents these selectors from applying.

Help me compare my StatusCard class names with my status-pill CSS selectors.

✔️ Awesome, I've got everything!

Your stylesheet now gives every state a structured card plus a readable status pill.

ⓧ I'd like to double check the full code

Compare your saved src/theme.css with this complete stylesheet for the standalone StatusCard stories.

:root {
  color: #172033;
  background: #eef2f7;
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  font-synthesis: none;
  text-rendering: optimizeLegibility;
  --page: #eef2f7;
  --surface: #ffffff;
  --surface-muted: #f7f9fc;
  --text: #172033;
  --text-muted: #526078;
  --border: #cbd5e1;
  --focus: #1d4ed8;
}

* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: var(--page); color: var(--text); }
.story-shell { min-height: 100vh; padding: 2rem; background: var(--page); }
.story-shell .status-card { max-width: 30rem; }

.status-card {
  --status-bg: #e2e8f0;
  --status-text: #334155;
  --status-symbol-bg: #475569;
  --status-symbol-text: #ffffff;
  padding: 1.25rem;
  border: 1px solid var(--border);
  border-top: 0.3rem solid var(--status-symbol-bg);
  border-radius: 1rem;
  background: var(--surface);
  box-shadow: 0 0.6rem 1.8rem rgb(15 23 42 / 8%);
}

.status-card--operational { --status-bg: #d1fae5; --status-text: #065f46; --status-symbol-bg: #047857; }
.status-card--degraded { --status-bg: #fef3c7; --status-text: #78350f; --status-symbol-bg: #b45309; }
.status-card--offline { --status-bg: #fee2e2; --status-text: #7f1d1d; --status-symbol-bg: #b91c1c; }
.status-card__heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
.status-card__eyebrow, .dashboard__eyebrow, .incident-banner__eyebrow, .incident-details__eyebrow { margin: 0 0 0.35rem; color: var(--text-muted); font-size: 0.75rem; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase; }
.status-card h2, .incident-banner h2, .incident-details h2, .dashboard h1 { margin: 0; line-height: 1.15; }
.status-card h2 { font-size: 1.15rem; }
.status-pill { display: inline-flex; align-items: center; flex: 0 0 auto; gap: 0.45rem; padding: 0.35rem 0.6rem; border-radius: 999px; background: var(--status-bg); color: var(--status-text); font-size: 0.78rem; font-weight: 800; }
.status-pill__symbol { display: grid; width: 1.45rem; height: 1.45rem; place-items: center; border-radius: 50%; background: var(--status-symbol-bg); color: var(--status-symbol-text); font-size: 0.65rem; }
.status-card__detail { min-height: 3rem; margin: 1.4rem 0 1rem; color: var(--text); line-height: 1.55; }
.status-card__updated { margin: 0; color: var(--text-muted); font-size: 0.82rem; }
Verify every state in Storybook

The story decorator gives every state the same test surface. The existing story arguments then exercise the final component with operational, degraded, and offline data.

  • Switch back to Neovim.
  • Open src/StatusCard.stories.jsx in the existing Neovim session.
  • Replace the metadata object above export default meta; with this block:
const meta = {
  title: 'Components/StatusCard',
  component: StatusCard,
  decorators: [
    (Story) => (
      <div className="story-shell">
        <Story />
      </div>
    ),
  ],
};

What Does the Decorator Do?

  • The decorator wraps every status-card story in the same story-shell container.
  • The shared wrapper makes visual comparisons consistent across all three states.
  • Confirm that the file still imports StatusCard from ./StatusCard.
  • Confirm that the file still exports Operational.
  • Confirm that the file still exports Degraded.
  • Confirm that the file still exports Offline.
  • Save src/StatusCard.stories.jsx.
  • Switch back to the open Storybook browser tab.

You will see the selected card inside the padded story surface. The same wrapper appears when you move between all three stories.

Did the Story Stop Rendering?

Check that export default meta; remains directly below the metadata object. Confirm that each named story remains below that export.

Help me debug my StatusCard story metadata or decorator.

✔️ Awesome, I've got everything!

Your three final status stories now share the same comparison surface.

ⓧ I'd like to double check the full code

Compare your saved src/StatusCard.stories.jsx with this complete file.

import { StatusCard } from './StatusCard';

const meta = {
  title: 'Components/StatusCard',
  component: StatusCard,
  decorators: [
    (Story) => (
      <div className="story-shell">
        <Story />
      </div>
    ),
  ],
};

export default meta;

export const Operational = {
  args: {
    name: 'DNS filtering',
    status: 'operational',
    detail: 'Queries are resolving normally across the home network.',
    updatedAt: '2 minutes ago',
  },
};

export const Degraded = {
  args: {
    name: 'DNS filtering',
    status: 'degraded',
    detail: 'Responses are slower than the expected baseline.',
    updatedAt: '1 minute ago',
  },
};

export const Offline = {
  args: {
    name: 'DNS filtering',
    status: 'offline',
    detail: 'The service is not responding to health checks.',
    updatedAt: 'just now',
  },
};

Before you run the final checks, do you expect any state to depend on color for its meaning now?

  • Select the Operational story under Components/StatusCard.
  • Run the Accessibility panel check again.
  • Review Violations.
  • Review Passes.
  • Review Incomplete.
  • Repeat the check for the Degraded story.
  • Repeat the check for the Offline story.

Each card reports no automated violations. That completes the automated part of the check.

  • Use the vision-simulation toolbar from the previous step to remove color as a useful cue.
  • Inspect the Operational story.
  • Inspect the Degraded story.
  • Inspect the Offline story.

You can still identify every state through its visible label plus symbol. The service heading, detail, and updated time remain readable without color.

What Does This Result Prove?

Automated checks catch detectable problems in the rendered component. The color-removal check confirms that meaning also survives a visual condition the original cards failed.

That combination gives your accessibility case study both test evidence and a manual observation.

Your service states now communicate through structure, text, symbols, and color. Next, you will compose them into the complete HomeLab Pulse dashboard.

Compose the HomeLab Pulse Dashboard

Your accessible StatusCard now communicates each service state through readable text plus distinct symbols. The isolated stories have also passed their automated accessibility checks.

Isolated components become stronger portfolio evidence when they remain clear inside a realistic page. This step uses Storybook to compose the cards into a summary with an active incident plus responsive behavior.

In this step, get ready to:
  • Compose the overall summary plus the degraded-service incident.
  • Render three services through the reusable StatusCard component.
  • Test the dashboard at a narrow width plus confirm visible keyboard focus.
Build the dashboard summary

The dashboard header gives the individual cards a shared context. Its summary lets you understand the overall condition before reading any service detail.

  • Use the edit command in Neovim to create src/StatusDashboard.jsx inside the homelab-pulse/src folder.
  • Paste this initial dashboard shell into the new file:
import { StatusCard } from './StatusCard';

export function StatusDashboard() {
  return (
    <main className="dashboard">
      <header className="dashboard__header">
        <div>
          <p className="dashboard__eyebrow">HomeLab Pulse</p>
          <h1>Service status</h1>
          <p className="dashboard__intro">A resilient view of the services that keep the home network useful.</p>
        </div>
        <div className="summary" aria-label="Overall service summary">
          <span className="summary__label">Current state</span>
          <strong>2 operational</strong>
          <span>1 degraded</span>
        </div>
      </header>
    </main>
  );
}

What Does This Code Do?

  • The main element gives the dashboard one primary content landmark.
  • The header introduces HomeLab Pulse with a visible page heading.
  • The summary exposes the operational count plus the degraded count as readable text.
  • Save src/StatusDashboard.jsx.
  • Confirm that Neovim keeps src/StatusDashboard.jsx open from the homelab-pulse/src folder.

Couldn’t Save the Dashboard File?

Confirm that the path begins with the existing src folder. Check that the filename ends with .jsx.

Save any pending changes in your previous Neovim buffer before switching files.

Help me create the dashboard file in Neovim.

A Storybook story gives the new page its own route in the sidebar. The first visible version proves that the dashboard module loads before more content is added.

  • Use Neovim’s edit command to create src/StatusDashboard.stories.jsx inside the homelab-pulse/src folder.
  • Paste this page story into the new file:
import { StatusDashboard } from './StatusDashboard';

const meta = {
  title: 'Pages/HomeLab Pulse',
  component: StatusDashboard,
};

export default meta;

export const Overview = {};

How Does the Story Work?

  • The metadata places the page under Pages/HomeLab Pulse in the Storybook sidebar.
  • The component field connects the story to StatusDashboard.
  • The named Overview export creates the first dashboard story.
  • Save src/StatusDashboard.stories.jsx.
  • Switch back to the browser from earlier.
  • Refresh Storybook.
  • Expand Pages/HomeLab Pulse in the sidebar.
  • Select Overview.

You’ll see the HomeLab Pulse heading plus the overall service summary. That first page-level slice is now rendering inside Storybook.

Don’t See the Overview Story?

Confirm that the story file is named StatusDashboard.stories.jsx. Check that its import points to ./StatusDashboard.

Confirm that StatusDashboard.jsx exports StatusDashboard with the same capitalization.

Help me find my missing Storybook page.

✔️ Awesome, I've got everything!

Great. Your saved story now publishes the first HomeLab Pulse page in Storybook.

ⓧ I'd like to double check the full code

import { StatusDashboard } from './StatusDashboard';

const meta = {
  title: 'Pages/HomeLab Pulse',
  component: StatusDashboard,
};

export default meta;

export const Overview = {};
Compose the services and incident

A deterministic service list keeps every dashboard demonstration repeatable. Each object supplies the same inputs that the accessible StatusCard already understands.

  • Return to src/StatusDashboard.jsx in Neovim.
  • Find the StatusCard import at the top of the file.
  • Add this service data directly below the import:
const services = [
  {
    name: 'DNS filtering',
    status: 'degraded',
    detail: 'Responses are slower than the expected baseline.',
    updatedAt: '1 minute ago',
  },
  {
    name: 'Backup archive',
    status: 'operational',
    detail: 'The latest encrypted snapshot completed successfully.',
    updatedAt: '12 minutes ago',
  },
  {
    name: 'Remote access',
    status: 'operational',
    detail: 'Secure shell access is available on the local network.',
    updatedAt: '4 minutes ago',
  },
];

What Does the Service Data Represent?

  • DNS filtering carries the degraded state that drives the active incident.
  • Backup archive plus Remote access demonstrate the operational state in different service contexts.
  • Each object includes the readable detail plus the latest update time required by StatusCard.
  • Save src/StatusDashboard.jsx.
  • Switch back to Storybook.
  • Refresh the Overview story.

You’ll still see the dashboard summary without an error overlay. This confirms that all three service objects use valid JavaScript syntax.

Seeing an Error After Adding Services?

Check the comma after each service object. Confirm that the array closes with ];.

Compare each property name with name, status, detail, plus updatedAt.

Help me fix my service data.

The dashboard body now turns the service data into cards. It also connects the active incident link to a detailed explanation further down the page.

  • Find the closing </header> tag inside StatusDashboard.
  • Add this dashboard body directly below that tag:
      <aside className="incident-banner" aria-labelledby="incident-title">
        <div>
          <p className="incident-banner__eyebrow">Active incident</p>
          <h2 id="incident-title">DNS responses are slower than expected</h2>
        </div>
        <a href="#incident-details">Review incident</a>
      </aside>

      <section className="service-grid" aria-label="Service health">
        {services.map((service) => (
          <StatusCard key={service.name} {...service} />
        ))}
      </section>

      <section className="incident-details" id="incident-details" aria-labelledby="incident-details-title">
        <p className="incident-details__eyebrow">Incident details</p>
        <h2 id="incident-details-title">Investigation in progress</h2>
        <p>DNS resolution is available, but recent responses exceed the normal latency baseline. No action is required from household users.</p>
      </section>

How Does the Dashboard Body Work?

  • The incident banner gives the degraded DNS state a page-level explanation.
  • The href="#incident-details" value connects the link to the matching section ID.
  • The services.map() call creates one StatusCard for each service object.
  • The service name provides a stable key for each rendered card.
  • Save src/StatusDashboard.jsx.
  • Switch back to the Overview story.
  • Refresh the page.

You’ll see the degraded DNS incident plus three service cards. You’ll also see the incident-details section below the cards.

Don’t See All Three Cards?

Confirm that services.map() appears inside the service-grid section. Check that StatusCard begins with a capital letter.

Confirm that each spread uses {...service}. A different variable name prevents the card props from reaching the component.

Help me restore the three dashboard cards.

✔️ Awesome, I've got everything!

Your dashboard now contains the full summary plus incident content plus all three service cards.

ⓧ I'd like to double check the full code

import { StatusCard } from './StatusCard';

const services = [
  {
    name: 'DNS filtering',
    status: 'degraded',
    detail: 'Responses are slower than the expected baseline.',
    updatedAt: '1 minute ago',
  },
  {
    name: 'Backup archive',
    status: 'operational',
    detail: 'The latest encrypted snapshot completed successfully.',
    updatedAt: '12 minutes ago',
  },
  {
    name: 'Remote access',
    status: 'operational',
    detail: 'Secure shell access is available on the local network.',
    updatedAt: '4 minutes ago',
  },
];

export function StatusDashboard() {
  return (
    <main className="dashboard">
      <header className="dashboard__header">
        <div>
          <p className="dashboard__eyebrow">HomeLab Pulse</p>
          <h1>Service status</h1>
          <p className="dashboard__intro">A resilient view of the services that keep the home network useful.</p>
        </div>
        <div className="summary" aria-label="Overall service summary">
          <span className="summary__label">Current state</span>
          <strong>2 operational</strong>
          <span>1 degraded</span>
        </div>
      </header>

      <aside className="incident-banner" aria-labelledby="incident-title">
        <div>
          <p className="incident-banner__eyebrow">Active incident</p>
          <h2 id="incident-title">DNS responses are slower than expected</h2>
        </div>
        <a href="#incident-details">Review incident</a>
      </aside>

      <section className="service-grid" aria-label="Service health">
        {services.map((service) => (
          <StatusCard key={service.name} {...service} />
        ))}
      </section>

      <section className="incident-details" id="incident-details" aria-labelledby="incident-details-title">
        <p className="incident-details__eyebrow">Incident details</p>
        <h2 id="incident-details-title">Investigation in progress</h2>
        <p>DNS resolution is available, but recent responses exceed the normal latency baseline. No action is required from household users.</p>
      </section>
    </main>
  );
}
Make the layout responsive

The existing CSS gives each card its accessible visual language. The next rules arrange those cards inside a page-level hierarchy.

  • Return to src/theme.css in Neovim.
  • Find the existing .status-card__updated rule.
  • Add these dashboard-header rules below that rule:
.dashboard { min-height: 100vh; padding: clamp(1.25rem, 4vw, 4rem); background: var(--page); }
.dashboard__header { display: flex; align-items: flex-end; justify-content: space-between; max-width: 72rem; margin: 0 auto 1.5rem; gap: 2rem; }
.dashboard h1 { max-width: 11ch; font-size: clamp(2.4rem, 7vw, 5.5rem); letter-spacing: -0.055em; }
.dashboard__intro { max-width: 42rem; margin: 1rem 0 0; color: var(--text-muted); font-size: 1.05rem; line-height: 1.6; }
.summary { display: grid; flex: 0 0 auto; gap: 0.25rem; min-width: 12rem; padding: 1rem; border: 1px solid var(--border); border-radius: 0.9rem; background: var(--surface); }
.summary__label { color: var(--text-muted); font-size: 0.78rem; }

What Do These Layout Rules Change?

  • The dashboard uses the existing page token for a consistent background.
  • The header places the introduction beside the overall summary on wide screens.
  • The heading scales with the viewport while retaining a readable maximum width.
  • The summary uses the existing surface plus border tokens.
  • Save src/theme.css.
  • Switch back to the Overview story.
  • Refresh the page.

You’ll see a larger page heading beside a bordered summary panel. The dashboard now has a clear top-level hierarchy.

Does the Header Still Look Unstyled?

Confirm that the new rules appear below .status-card__updated. Check that every selector begins with a full stop where shown.

Confirm that .storybook/preview.js still imports ../src/theme.css.

Help me diagnose the dashboard header styles.

The incident plus card grid need their own composition rules. The focus selector also makes the incident link visible when it receives keyboard focus.

  • Find the new .summary__label rule in src/theme.css.
  • Add these incident plus service-layout rules directly below it:
.incident-banner { display: flex; align-items: center; justify-content: space-between; max-width: 72rem; margin: 0 auto 1rem; padding: 1rem 1.2rem; gap: 1rem; border: 1px solid #d97706; border-radius: 1rem; background: #fffbeb; }
.incident-banner h2 { color: #78350f; font-size: 1rem; }
.incident-banner a { flex: 0 0 auto; color: #1e3a8a; font-weight: 800; text-underline-offset: 0.2em; }
.incident-banner a:focus-visible { border-radius: 0.2rem; outline: 0.2rem solid var(--focus); outline-offset: 0.25rem; }
.service-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); max-width: 72rem; margin: 0 auto; gap: 1rem; }
.incident-details { max-width: 72rem; margin: 1rem auto 0; padding: clamp(1.25rem, 3vw, 2rem); border: 1px solid var(--border); border-radius: 1rem; background: var(--surface-muted); }
.incident-details p:last-child { max-width: 55rem; margin-bottom: 0; color: var(--text-muted); line-height: 1.65; }

How Does the Composition Stay Clear?

  • The incident banner separates the active problem from the service collection.
  • The grid gives each card one equal-width column on wide screens.
  • The incident-details section uses the muted surface token to separate supporting information.
  • The :focus-visible selector draws an outline for keyboard focus.
  • Save src/theme.css.
  • Switch back to the Overview story.
  • Refresh the page.

You’ll see the three cards arranged in one row on a wide canvas. The incident banner plus details section now share the same centered page width.

Are the Cards Still Stacked on a Wide Screen?

Confirm that .service-grid includes grid-template-columns: repeat(3, minmax(0, 1fr)).

Check that the component uses className="service-grid" with the same spelling.

Help me fix the dashboard grid.

A responsive layout must preserve every service detail when horizontal space disappears. The narrow rules convert the grid to one column plus stack the elements that need more room.

  • Move to the bottom of src/theme.css.
  • Add these responsive rules after the incident-details styles:
@media (max-width: 52rem) {
  .dashboard__header { align-items: stretch; flex-direction: column; }
  .summary { min-width: 0; }
  .service-grid { grid-template-columns: 1fr; }
}

@media (max-width: 34rem) {
  .story-shell, .dashboard { padding: 1rem; }
  .incident-banner, .status-card__heading { align-items: flex-start; flex-direction: column; }
}

What Do the Breakpoints Protect?

  • At 52rem or below, the header stacks vertically.
  • The service grid becomes one column so every card keeps enough reading width.
  • At 34rem or below, the page uses tighter padding.
  • The incident banner plus card heading stack to prevent content collisions.
  • Save src/theme.css.

✔️ Awesome, I've got everything!

Your stylesheet now contains the complete dashboard layout plus its responsive behavior.

ⓧ I'd like to double check the full code

:root {
  color: #172033;
  background: #eef2f7;
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  font-synthesis: none;
  text-rendering: optimizeLegibility;
  --page: #eef2f7;
  --surface: #ffffff;
  --surface-muted: #f7f9fc;
  --text: #172033;
  --text-muted: #526078;
  --border: #cbd5e1;
  --focus: #1d4ed8;
}

* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: var(--page); color: var(--text); }
.story-shell { min-height: 100vh; padding: 2rem; background: var(--page); }
.story-shell .status-card { max-width: 30rem; }

.status-card {
  --status-bg: #e2e8f0;
  --status-text: #334155;
  --status-symbol-bg: #475569;
  --status-symbol-text: #ffffff;
  padding: 1.25rem;
  border: 1px solid var(--border);
  border-top: 0.3rem solid var(--status-symbol-bg);
  border-radius: 1rem;
  background: var(--surface);
  box-shadow: 0 0.6rem 1.8rem rgb(15 23 42 / 8%);
}

.status-card--operational { --status-bg: #d1fae5; --status-text: #065f46; --status-symbol-bg: #047857; }
.status-card--degraded { --status-bg: #fef3c7; --status-text: #78350f; --status-symbol-bg: #b45309; }
.status-card--offline { --status-bg: #fee2e2; --status-text: #7f1d1d; --status-symbol-bg: #b91c1c; }
.status-card__heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
.status-card__eyebrow, .dashboard__eyebrow, .incident-banner__eyebrow, .incident-details__eyebrow { margin: 0 0 0.35rem; color: var(--text-muted); font-size: 0.75rem; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase; }
.status-card h2, .incident-banner h2, .incident-details h2, .dashboard h1 { margin: 0; line-height: 1.15; }
.status-card h2 { font-size: 1.15rem; }
.status-pill { display: inline-flex; align-items: center; flex: 0 0 auto; gap: 0.45rem; padding: 0.35rem 0.6rem; border-radius: 999px; background: var(--status-bg); color: var(--status-text); font-size: 0.78rem; font-weight: 800; }
.status-pill__symbol { display: grid; width: 1.45rem; height: 1.45rem; place-items: center; border-radius: 50%; background: var(--status-symbol-bg); color: var(--status-symbol-text); font-size: 0.65rem; }
.status-card__detail { min-height: 3rem; margin: 1.4rem 0 1rem; color: var(--text); line-height: 1.55; }
.status-card__updated { margin: 0; color: var(--text-muted); font-size: 0.82rem; }

.dashboard { min-height: 100vh; padding: clamp(1.25rem, 4vw, 4rem); background: var(--page); }
.dashboard__header { display: flex; align-items: flex-end; justify-content: space-between; max-width: 72rem; margin: 0 auto 1.5rem; gap: 2rem; }
.dashboard h1 { max-width: 11ch; font-size: clamp(2.4rem, 7vw, 5.5rem); letter-spacing: -0.055em; }
.dashboard__intro { max-width: 42rem; margin: 1rem 0 0; color: var(--text-muted); font-size: 1.05rem; line-height: 1.6; }
.summary { display: grid; flex: 0 0 auto; gap: 0.25rem; min-width: 12rem; padding: 1rem; border: 1px solid var(--border); border-radius: 0.9rem; background: var(--surface); }
.summary__label { color: var(--text-muted); font-size: 0.78rem; }
.incident-banner { display: flex; align-items: center; justify-content: space-between; max-width: 72rem; margin: 0 auto 1rem; padding: 1rem 1.2rem; gap: 1rem; border: 1px solid #d97706; border-radius: 1rem; background: #fffbeb; }
.incident-banner h2 { color: #78350f; font-size: 1rem; }
.incident-banner a { flex: 0 0 auto; color: #1e3a8a; font-weight: 800; text-underline-offset: 0.2em; }
.incident-banner a:focus-visible { border-radius: 0.2rem; outline: 0.2rem solid var(--focus); outline-offset: 0.25rem; }
.service-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); max-width: 72rem; margin: 0 auto; gap: 1rem; }
.incident-details { max-width: 72rem; margin: 1rem auto 0; padding: clamp(1.25rem, 3vw, 2rem); border: 1px solid var(--border); border-radius: 1rem; background: var(--surface-muted); }
.incident-details p:last-child { max-width: 55rem; margin-bottom: 0; color: var(--text-muted); line-height: 1.65; }

@media (max-width: 52rem) {
  .dashboard__header { align-items: stretch; flex-direction: column; }
  .summary { min-width: 0; }
  .service-grid { grid-template-columns: 1fr; }
}

@media (max-width: 34rem) {
  .story-shell, .dashboard { padding: 1rem; }
  .incident-banner, .status-card__heading { align-items: flex-start; flex-direction: column; }
}

The 320 CSS pixels check tests whether information survives a narrow viewport. It supports the reflow expectations described by WCAG 2.2.

Before you resize the canvas, do you expect any service details to disappear at 320 CSS pixels?

  • Switch back to Storybook in the browser.
  • Select Pages/HomeLab Pulse in the sidebar.
  • Select the Overview story.
  • Resize the canvas to about 320 CSS pixels.
  • Press Tab until Review incident receives focus.

At about 320 CSS pixels, you’ll see all three cards reflow into one column without losing their labels or details. The Review incident link displays a clear focus outline.

Does the Narrow Layout Break?

Confirm that the 52rem media query sets .service-grid to one column. Check that both media queries close with matching braces.

If the focus outline stays hidden, click an empty part of the page first. Press Tab again to use keyboard focus.

Help me diagnose the responsive or focus behavior.

Your isolated status components now form a responsive service dashboard with a keyboard-visible incident path. Next, you’ll build the static Storybook and serve it from your Raspberry Pi.

Build and Serve It from the Raspberry Pi

Your responsive Storybook now proves that HomeLab Pulse works across service states and narrow screens. The final step turns that local workshop into an artifact served by your Raspberry Pi.

You will generate a static build that contains the complete component workshop. You will copy that build to the Pi without changing its existing DNS service.

In this step, get ready to:
  • Build the completed Storybook into a static directory.
  • Copy the static directory to the Raspberry Pi.
  • Serve the dashboard to your Mac over the local network.
Build the static Storybook

A static Storybook packages the sidebar and stories into files that any web server can deliver. The build preserves the completed source files in your local homelab-pulse project.

  • Return to the macOS Terminal from earlier.
  • Stop the local Storybook process by pressing Control-C.

Before you run the build, predict whether the command creates a static directory or starts another development server.

  • Generate the static Storybook by running this command:
npm run build-storybook

What Does This Command Do?

The build-storybook script runs Storybook's production build. It bundles the completed stories and styles into storybook-static.

The resulting directory is a static web application. It can be served without the Storybook development process.

The build completes and creates storybook-static inside homelab-pulse. Your complete Storybook is now ready to copy.

Does the Build Fail?

Confirm that Terminal is still inside the homelab-pulse directory. The build script comes from the existing package.json file.

Check the first reported source file if the build identifies a syntax problem. Fix that file before rebuilding.

Help me diagnose my static Storybook build.

Copy the build to the Raspberry Pi

Secure Copy Protocol, or SCP, transfers a directory through an encrypted connection. Its recursive option preserves every file inside storybook-static.

The transfer uses the same account details as SSH. Terminal may ask you to confirm the host or enter the Pi account password.

  • Replace <username> with your Raspberry Pi username.
  • Replace <pi_ip_address> with your Raspberry Pi local IP address.
  • Copy the static directory to the Pi user's home directory by running this command from homelab-pulse:
scp -r storybook-static/ <username>@<pi_ip_address>:

What Does This Command Do?

  • The scp command transfers files through the Pi's SSH connection.
  • The -r option includes every nested file in storybook-static.
  • The final colon targets the selected user's home directory on the Pi.

The transfer finishes and returns control to your Mac terminal. The Pi user's home directory now contains storybook-static.

Does the Copy Fail?

Confirm that the Pi is powered on and connected to the same local network as your Mac. Check that the username and IP address match the account you normally use for SSH.

Confirm that storybook-static exists inside homelab-pulse. The recursive copy needs that build directory as its source.

Help me diagnose my SCP transfer.

  • Replace <username> with the same Raspberry Pi username.
  • Replace <ip address> with the same Raspberry Pi local IP address.
  • Connect to the Raspberry Pi by running this command:
ssh <username>@<ip address>

What Does This Command Do?

The ssh command opens an encrypted remote shell on the Raspberry Pi. The terminal prompt changes because subsequent commands run on the Pi.

You are now signed in to the Raspberry Pi. Keep using this SSH terminal for the remaining server commands.

Raspberry Pi OS includes Python 3. This project needs the python command to resolve to that interpreter.

  • Confirm the Pi exposes Python 3 through the required command by running:
python --version

What Does This Command Check?

The --version option prints the interpreter version and exits. An output beginning with Python 3 confirms that the serving command uses Python 3.

The terminal reports a Python 3 version. The Pi is ready to serve the static files.

Is the Python Command Unavailable?

Confirm that the Pi uses Raspberry Pi OS as targeted by this project. The serving path requires Python 3 to be available through the python command.

Help me check Python on my Raspberry Pi.

  • Enter the copied static directory by running this command:
cd storybook-static

What Does This Command Do?

The command changes the Pi shell's current directory to storybook-static. The web server serves files from this location.

Is the Directory Missing?

Confirm that the SCP transfer completed before you connected over SSH. The copied storybook-static directory should sit inside the signed-in user's home directory.

Help me locate the copied Storybook directory.

Keep Port 9000 Private

Python's http.server performs only basic security checks. Keep port 9000 on your private local network.

Do not expose this server through router port forwarding. This server is for a local demonstration.

  • Serve the copied Storybook on port 9000 by running this command:
python -m http.server 9000

What Does This Command Do?

The -m option runs the http.server module. The module serves the current storybook-static directory on port 9000.

The process stays attached to the SSH terminal while it accepts requests. It does not change the Pi's existing DNS service.

The SSH terminal remains occupied by the running server. That attached process is the live host for your Storybook demonstration.

  • Keep the SSH terminal open while you test the deployment.

Does the Server Stop Immediately?

Confirm that the Python check reported version 3. Confirm that the shell entered storybook-static before starting the server.

If another process already uses port 9000, stop that process before retrying. Keep the existing DNS service running.

Help me diagnose the Python server on port 9000.

Load the dashboard over the LAN

A local area network address lets your Mac request the files directly from the Raspberry Pi. The browser should receive the same Storybook interface that previously ran on your Mac.

Before you load the address, predict whether the browser will show a file listing or the complete Storybook interface.

  • Switch back to the browser from earlier.
  • Enter http://<pi-ip>:9000 in the address bar.
  • Load the address.

You will see the Storybook interface load from the Raspberry Pi. The sidebar contains the stories from the static build.

  • Select Pages/HomeLab Pulse in the Storybook sidebar.
  • Select the Overview story.

You will see the HomeLab Pulse summary and degraded DNS incident. You will also see all three service cards.

Does the LAN Address Fail to Load?

Confirm that the Mac and Raspberry Pi are connected to the same local network. Check that the browser address uses the Pi's IP address with port 9000.

Return to the attached SSH terminal and confirm that the Python server is still running. Closing that terminal stops the demonstration server.

Help me diagnose the LAN connection.

You have moved HomeLab Pulse from a local development workshop to a working hardware deployment. Your Raspberry Pi now serves the accessible dashboard across your private network.

Secret mission

Add a High-Contrast Theme

Give HomeLab Pulse a high-contrast dashboard theme without changing the StatusCard markup. Drive the palette through tokens, expose it as a Storybook story, then redeploy it to the Raspberry Pi.

Clean Up Your Resources

Clean Up Your Resources

Choose whether to keep the HomeLab Pulse demo available, pause it for later, or remove it entirely. The project costs $0 because every resource runs on your Mac or Raspberry Pi.

Resources you used:

  • The local homelab-pulse folder containing your React source, Storybook configuration, pinned dependencies, and package-lock.json.
  • The local storybook-static build containing the default and high-contrast stories.
  • The copied ~/storybook-static directory on the Raspberry Pi.
  • The attached Python http.server process serving the Pi build on port 9000.

Keep everything running

No action needed. Choose this if you are still using the Raspberry Pi deployment for local-network demonstrations.

  • Leave the attached Python server running on port 9000.
  • Keep the local homelab-pulse folder so you can update the components or rebuild the stories.
  • Keep ~/storybook-static on the Raspberry Pi so the current build remains available over your local network.

Pause - I'll come back to this later

Shut down the attached server to free port 9000. Your project files remain ready for another demonstration.

  • Return to the attached Pi terminal from earlier.
  • Press Control-C to stop the Python server.
  • Refresh the LAN URL from your Mac.

The Storybook page stops loading. This confirms that the server released port 9000.

Does the LAN page still load?

Confirm that you stopped the server in the Pi terminal that was serving the copied directory. A cached page may remain visible until you refresh the browser.

Still stuck? Help me confirm whether my Raspberry Pi Python server is still running on port 9000.

Delete - I don't want to use this again

This cleanup permanently removes the HomeLab Pulse files from both machines. Your Raspberry Pi, Python installation, and SSH setup remain intact.

  • Return to the attached Pi terminal from earlier.
  • Press Control-C to stop the Python server.
  • Remove the deployed build from the Pi user’s home directory by running these commands:
rm -rf ~/storybook-static
ls ~

What Does This Pi Cleanup Do?

  • The first command permanently removes the copied static build from the Pi user’s home directory.
  • The second command lists the remaining home-directory contents so you can verify the result.

You should no longer see storybook-static in the Pi home-directory listing.

Still See the Pi Build?

Confirm that the terminal is connected to the same Pi account that received the copied directory. The home-directory shortcut points to the currently connected account.

Need a hand? Help me remove the copied Storybook directory from my Raspberry Pi.

  • Return to the Mac Terminal tab you used for the local project.
  • Confirm that the final folder name in the terminal prompt is homelab-pulse.
  • Remove the local project and its generated build by running these commands:
cd ..
rm -rf homelab-pulse
ls

What Does This Mac Cleanup Do?

  • The first command moves Terminal to the folder that contains your project.
  • The second command permanently removes the project source, dependencies, lockfile, and local static build.
  • The final command lists the remaining folders so you can confirm that the project is gone.

You should no longer see homelab-pulse in the Mac directory listing.

Still See the Local Project?

Confirm that Terminal was inside homelab-pulse before the first command moved up one level. Running the cleanup from a different location checks the wrong parent folder.

Need help checking the location? Help me safely remove my local HomeLab Pulse project.

  • Refresh the LAN URL from your Mac.

The Storybook page no longer loads. This confirms that the Pi server has stopped.

Nice Work!

Nice Work!

You did it! HomeLab Pulse is now an accessible Storybook design system served by your Raspberry Pi across the local network.

You've learned how to:

  • Build reusable service-status components for operational, degraded, and offline states.
  • Transform a planned color-only accessibility failure into status cards that remain clear through labels, symbols, structure, and readable contrast.
  • Serve a responsive HomeLab Pulse dashboard from your Raspberry Pi as a static Storybook build on the local network.
  • Secret Mission: Add a high-contrast dashboard theme through design token overrides without changing the status-card markup.

Ready to quiz yourself?