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:
- Search the page for the class names of your components (
btn,row). Each hit is a brick you forgot to use. - Replace one block with the component, passing props. Delete the now-dead CSS from the page.
- Move decisions up. The handler that changes
notesstays in the page. The component getsondelete={() => remove(note.id)}. - Run
npm testandnpm run test:visual. They should stay green: the bricks did not change, only where they are used. - 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 intotokens.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.
- Component.
src/lib/components/Tag.svelte: two props with defaults, one snippet, one event. JSDoc on every prop, and colours from tokens only. - Story.
Tag.stories.sveltewith Neutral, Accent and Removable, plusautodocs. The Removable story logsonremovein Actions. - Unit test.
Tag.test.js: (a) it shows its label and no remove button by default; (b) it callsonremoveonce when the remove button is clicked. - Use it. Export Tag from
src/lib/index.jsand show a note's tags in one page. Clicking × removes that tag from a$statearray.
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.jsonfields 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?
-
fetchdoes not work inside a.sveltefile - 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.cssis imported in+layout.svelte - That the story has a value for each of its
args - That
autodocsis listed in thetagsof 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
onaddevent for the page - Move its cart logic into a
tokens.cssvariable 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.