Skip to content

Workshop and revision

Hands up: do you have a component in your library that no page of your app uses? And a page that still has a hand-written <button>? Usually the same people raise their hand both times.

For three weeks you have made bricks, a catalogue, quality control, an instruction booklet and a box. The curriculum's last criterion is the one a user would care about: applications whose interface uses the created components. A brick in a drawer is not a model. Nothing here is new. On this page you build with what you have, and you rehearse working against the clock.

What you will learn

  • Take stock of your library with a component inventory
  • Integrate your components in your app: pages own the data, components show it and report
  • Check an integration with the tests you already have
  • Build a small component from a spec against the clock
  • Know what to revise for the unit exam, and how to spend your time in it
  • The trap: "it works in the page" does not mean the component is done

Take stock: the inventory

A component inventory is a table with one row per component of your library, showing what each one has and what it is still missing.

Component Props and defaults Events, snippets, methods Stories Unit tests Visual tests Docs Used in page
Button variant, size, disabled children 5 4 6 Yes Notes, Edit

Fill in one row for each of yours. A dash is allowed when something does not apply. An empty cell is not. Empty cells are your to-do list. Which column has the most holes? Usually Used in page and Visual tests. That decides what you do first.

Integrate, one page at a time

Open a page that still has raw markup, and work in this order:

  1. Search the page for the class names of your components (btn, row). Each hit is a brick you forgot to use.
  2. Replace one block with the component, passing props. Delete the now-dead CSS from the page.
  3. Move decisions up. The handler that changes notes stays in the page. The component gets ondelete={() => remove(note.id)}.
  4. Run npm test and npm run test:visual. They should stay green: the bricks did not change, only where they are used.
  5. Look at the page's <style>. It has shrunk to layout only: grid, gap, max-width. Where did the colours go? Into the components, and from there into tokens.css.

Careful: keep the stories and tests

"Now that the component is in the app, I can delete its stories and tests." The page tests nothing by itself. Stories and tests are what let you change the brick next month without opening every page.

Integration checklist

# Check
1 Every page imports its components from #lib in one line, not from deep paths
2 No page contains a hand-written <button> that could be your Button
3 No page repeats markup that exists as a component
4 Pages own the data ($state, load). Components only receive props and report events
5 tokens.css is imported once in the app, in +layout.svelte. No hex colour in any page or component
6 npm test and npm run test:visual are green after the integration
7 npm run build finishes, and the app works in npm run preview
8 At least 3 of your own components are used in at least 2 different pages

When it goes wrong

Symptom What to do
A component fetches its own data (fetch inside NoteRow) Move it to the page or its load function, as in Consuming data. Ask yourself: could Storybook show this component without a server?
A component only fits one page, because it navigates with goto It should report (onopen) and let the page navigate
"I have to pass five props" Five props is normal. Use {...note} if the names match
Visual tests are red after the integration Did the component change, or only the page? If the component did, was it on purpose?
You have almost no library Extract one component from your most repeated markup, with one story and one test. One finished brick beats five half-made

Practice against the clock

Reading code you understand feels like knowing it. Writing it alone, with a timer, shows you what you really know. Here is a rehearsal: 30 minutes, alone, with no AI and no messages. Your own code from this unit and the official docs are allowed.

The spec: a Tag

A small label shown on a note, with an optional × to remove it.

Figma property Svelte prop Values Default
Tone tone neutral, accent neutral
Removable removable On, off Off
Label children Any text Required
(click on ×) onremove A callback None

Anatomy: 28 px tall, 10 px horizontal padding, 6 px gap, 14 px radius, 1 px border in --nb-ink, 13 px text at weight 500. Neutral has a --nb-paper background with --nb-ink text. Accent has --nb-accent as background and border, with --nb-paper text.

The four tasks

Do them in order, and make each one work before starting the next. A component with a story is worth more than four half-written files.

  1. Component. src/lib/components/Tag.svelte: two props with defaults, one snippet, one event. JSDoc on every prop, and colours from tokens only.
  2. Story. Tag.stories.svelte with Neutral, Accent and Removable, plus autodocs. The Removable story logs onremove in Actions.
  3. Unit test. Tag.test.js: (a) it shows its label and no remove button by default; (b) it calls onremove once when the remove button is clicked.
  4. Use it. Export Tag from src/lib/index.js and show a note's tags in one page. Clicking × removes that tag from a $state array.

Then swap seats with a classmate. Run npm test, open the stories, click ×, read Tag.svelte, and mark it out of 10 with this rubric.

Item Full marks when... Points
Component renders the spec Both tones match the spec, and colours come from tokens 2
Props and defaults tone = 'neutral', removable = false, and JSDoc on every prop 2
Snippet The label is {@render children()} 1
Event × calls onremove, the Tag does not remove itself, and the × has an accessible name 1.5
Story Three stories, autodocs, and onremove visible in Actions 1.5
Unit tests Two tests, both green, found by role or text (not by class) 1.5
Used in a page Exported from index.js, and a page renders tags and removes one from its $state 0.5

Check your work

Try it first. These stay closed until you open them.

Solution: Tag.svelte
<script>
    /**
     * @typedef {Object} Props
     * @property {'neutral' | 'accent'} [tone] The colour of the tag.
     * @property {boolean} [removable] Shows the remove button.
     * @property {() => void} [onremove] Called when the remove button is clicked.
     * @property {import('svelte').Snippet} children The label.
     */

    /** @type {Props} */
    let { tone = 'neutral', removable = false, onremove, children } = $props();
</script>

<span class="tag {tone}">
    {@render children()}
    {#if removable}
        <button type="button" aria-label="Remove" onclick={() => onremove?.()}>×</button>
    {/if}
</span>

<style>
    .tag {
        display: inline-flex;
        align-items: center;
        gap: 6px;
        height: 28px;
        padding: 0 10px;
        border: 1px solid var(--nb-ink);
        border-radius: 14px;
        font: 500 13px var(--nb-font);
    }
    .neutral {
        background: var(--nb-paper);
        color: var(--nb-ink);
    }
    .accent {
        background: var(--nb-accent);
        border-color: var(--nb-accent);
        color: var(--nb-paper);
    }
    button {
        border: 0;
        padding: 0;
        background: none;
        color: inherit;
        font: inherit;
        cursor: pointer;
    }
</style>
Solution: Tag.stories.svelte
<script module>
    import { defineMeta } from '@storybook/addon-svelte-csf';
    import { fn } from 'storybook/test';
    import Tag from './Tag.svelte';

    const { Story } = defineMeta({
        title: 'NoteBox/Tag',
        component: Tag,
        tags: ['autodocs'],
        args: { onremove: fn() }
    });
</script>

<Story name="Neutral">school</Story>

<Story name="Accent" args={{ tone: 'accent' }}>urgent</Story>

<Story name="Removable" args={{ removable: true }}>school</Story>
Solution: Tag.test.js
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { createRawSnippet } from 'svelte';
import { expect, test, vi } from 'vitest';
import Tag from './Tag.svelte';

const label = createRawSnippet(() => ({ render: () => '<span>school</span>' }));

test('shows its label and no remove button by default', () => {
    render(Tag, { children: label });

    expect(screen.getByText('school')).toBeInTheDocument();
    expect(screen.queryByRole('button', { name: 'Remove' })).not.toBeInTheDocument();
});

test('calls onremove once when the remove button is clicked', async () => {
    const user = userEvent.setup();
    const onremove = vi.fn();
    render(Tag, { children: label, removable: true, onremove });

    await user.click(screen.getByRole('button', { name: 'Remove' }));

    expect(onremove).toHaveBeenCalledOnce();
});
Solution: using it in a page
<script>
    import { Tag } from '#lib';

    let tags = $state(['urgent', 'home', 'weekend']);

    /** @param {string} tag */
    function remove(tag) {
        tags = tags.filter((t) => t !== tag);
    }
</script>

{#each tags as tag (tag)}
    <Tag tone={tag === 'urgent' ? 'accent' : 'neutral'} removable onremove={() => remove(tag)}>
        {tag}
    </Tag>
{/each}

Remember to add the export to src/lib/index.js: export { default as Tag } from './components/Tag.svelte';

Then ask yourself three questions. Did you have a working component but no story? Did you lose time on the CSS? Did you write the test last and run out of time? Read a rubric before you start: behaviour carries more points than pixels.

Revision map

Lesson What to be able to do
Storybook and your first component Name the tools and their jobs. Write a component and its stories
Props and default values $props with defaults, required props, ...rest, JSDoc. Figma property to prop
Methods, snippets and events Callback props, snippets (children, named, with a parameter), exported methods, $bindable
Unit tests with Vitest Arrange, act, assert. getByRole, queryBy, vi.fn(), await user.click
Visual tests and documentation Baseline, actual, diff. When to update snapshots. autodocs, and JSDoc to Docs
Packaging with npm svelte-package, npm pack, files, exports, peer dependencies, semver
This page Pages own the data. Components receive props and report events

How to approach the exam

The unit exam has a short theory part on paper and a larger practical part, and it covers all of the lessons above. In the practical part you work from a Figma spec, on a starter project that already has the tools installed. Your teacher gives you the date and the details in Classroom.

Allowed Not allowed
The starter project and the Figma spec Any AI (in exams, not even cs50.ai)
The official docs of Svelte, Storybook, Vitest and Testing Library Messaging, your own repositories, other people's screens

Copying means a zero.

Trap What to do instead
"The component works in the page, so it is done" Done means spec, story, test, docs. The page is the last step, not the proof
"I will write the tests at the end" At the end there is no time. Component, story, test: in that order, one at a time
"The CSS has to be pixel perfect first" Behaviour first. Read the marking criteria: they say where the points are
"A red test loses points, so I delete it" Deleting a failing test or bending its assertion is worse than handing it in red with a line of explanation
"My component fetches its own data, so it is self-contained" It is tied to one server, and it cannot be shown in Storybook or tested. Data comes in as props

Key takeaways

  • A brick in a drawer is not a model: the goal is an app whose interface uses your components.
  • A component inventory turns "what is left?" into a list. Empty cells are your to-do list.
  • Pages own the data. Components receive props and report events.
  • After integrating, your tests must stay green. Keep the stories and tests of every brick.
  • Against the clock: make each task work before starting the next, and put behaviour before pixels.
  • Never delete a failing test. Hand it in red, with an honest note.

Quick check

1. What is the purpose of a component inventory?
  • To replace the stories of each component in Storybook
  • To show what each component has and what is still missing
  • To generate the package.json fields of your library
  • To list the components that a page imports at runtime

One row per component, one column per thing it should have. The empty cells become your to-do list, which is much more useful than a vague feeling of being nearly done.

2. While integrating, where should the function that removes a note from the list live?
  • In the row component, because it has the Delete button
  • In tokens.css, together with all the shared values
  • In the page, because the page owns the list of notes
  • In the stories file, because it can be tested there

Pages own the data. The row only reports with ondelete, and the page decides. That is what keeps the row reusable in another screen.

3. Why is fetch inside a NoteRow a problem?
  • fetch does not work inside a .svelte file
  • It makes the row slower than doing it in the layout
  • It stops the row from receiving any other props
  • It ties the row to one server, so it is hard to show or test alone

A component that fetches its own data needs that server wherever it goes, including Storybook and your tests. Let the page or its load function fetch, and pass the result in as props.

4. You replaced a page's markup with your components. What do your existing tests tell you?
  • Whether the components still keep their promises after the change
  • Whether the page has any hand-written markup left in it
  • Whether every page of the app now uses your components
  • Whether the new page matches the design of the Figma file

The tests protect the bricks, so they should stay green when only the place they are used changes. If a visual test goes red, ask whether the component changed, and whether you meant it to.

5. You have a working component and 5 minutes left, and one test is failing. What is the best move?
  • Delete the failing test so the whole suite is green
  • Change the assertion until it matches what the code does
  • Spend the time making the CSS match the spec exactly
  • Hand it in red, with a line saying what you think is wrong

A green suite you bent is a lie about your code. A red test with an honest note shows that you understood the promise, even if you ran out of time to keep it.

6. After integrating, a story still works but your app shows the component with no colours. What should you check first?
  • That tokens.css is imported in +layout.svelte
  • That the story has a value for each of its args
  • That autodocs is listed in the tags of the file
  • That Storybook is running on its usual port number

The catalogue and the app are two websites. Storybook loads the tokens from .storybook/preview.js, and your app loads them from the layout, so one can work while the other does not.

7. A classmate has a ProductCard that reads the shopping cart directly and calls goto('/cart') when clicked. How would you make it reusable?
  • Copy it once for each page that needs a product card
  • Add a story that sets up the real cart and navigation
  • Give it props for its data and an onadd event for the page
  • Move its cart logic into a tokens.css variable instead

The card should know nothing about the page around it. Its data comes in as props, it reports the click, and the page decides whether to change the cart and where to navigate.

That closes Unit 4: you can design, build, test, document and package your own components. In Unit 5 your interfaces stop waiting for the mouse, and start listening to voice and watching gestures.