Help on the card: tooltips, tours and automatic screenshots¶
Exercise context-help-playwright-screenshots-annex.pdf · PDF
You are in the middle of a game and a card comes up that nobody remembers. Do you open the booklet? No. You read the card. The rule is printed on the thing itself, at the moment you need it. It is the kind of help people actually read.
Your app can do the same. The best documentation page is the one the user never has to open. On this page you put help on the screen, step by step, from a better label up to a guided tour. Then you hand your manual's pictures to a robot, because screenshots taken by hand go out of date the day you change a button.
What you will learn
- Define context-sensitive help and climb the help ladder in order
- Apply the three tooltip rules: dismissible, hoverable, persistent
- Build an accessible HelpTip component in Svelte 5
- Add a short guided tour with driver.js that the user starts and can leave
- Generate your manual's screenshots with a Playwright script
- The trap: why a tooltip is the wrong place for anything the user cannot do without
Help that knows where you are¶
Context-sensitive help is help that appears on the screen, about the control you are using, at the moment you are using it.
The context is where you are (this screen, this field) and what you are doing. It is an old idea: F1 in classic Windows programs opened the help page of the dialog in front of you, not page 1 of the manual.
The help ladder¶
There are five rungs, and more really is not better: every bubble is an apology for a label that could be clearer. Always try the lowest rung first. Each rung up costs more to build and more to read.
| # | Rung | What it is | Use it for |
|---|---|---|---|
| 0 | A better label | Rename the control: "Tags (comma separated)" | Everything you can. It is free and always visible |
| 1 | Inline help | One line of text under the field, always visible | Formats, limits, consequences: what the user needs to get it right |
| 2 | Tooltip | A small bubble that opens on hover or focus and closes again | Extra detail most people do not need. The name of an icon-only button |
| 3 | Guided tour | 3 or 4 bubbles in sequence, each pointing at one control | The first visit to a screen, when the user asks for it |
| 4 | Link to the manual | "Learn more", leading to a page of the docs site | Everything longer than two sentences |
Here is how to choose, on the booking screen of an imaginary cinema app:
| Control | Rung | Why |
|---|---|---|
| A field for a promo code | 1 · inline help | "8 letters, printed on your receipt." A rule about what to type belongs next to the field |
| An icon-only heart button | 2 · tooltip | "Add to favourites." The icon has no name, and most people work it out |
| A "Cancel all my bookings" button | None | No sentence makes a destructive action safe. Make it reversible |
That last row is forgiveness, from the 1987 Apple guidelines: give it an Undo, or at least a confirmation that names the consequence. A warning in a bubble does neither.
Careful: a tooltip is a place to hide text
Tooltips are not the modern way to explain important things. Important goes inline. A test takes ten seconds: cover the help with your hand. Can the user still finish the task? If not, it must be rung 0 or 1. A placeholder does not count as inline help either: it disappears when you type the first letter, exactly when you need it.
Three words for every tooltip¶
They come from WCAG 2.2, success criterion 1.4.13 Content on Hover or Focus (level AA), which you can place next to the rest of accessibility:
- Dismissible: you can close it without moving the pointer or the focus. In practice, Esc.
- Hoverable: you can move the pointer onto the bubble and it stays, to read it, select its text or use a screen magnifier.
- Persistent: it does not vanish on a timer while you are still there.
Add one more rule of ours: a tooltip opens on hover and on keyboard focus. Plenty of people move through a form by pressing Tab, and everything must work from the keyboard (WCAG 2.1.1).
Careful: the title attribute is not a tooltip you can rely on
The browser's title tooltip appears on mouse hover, after a delay. It does not appear on keyboard focus or on a touch screen, and you cannot style it or keep it open. WCAG leaves it out of 1.4.13 because the browser controls it, not you. That is no comfort for your users: use it for nothing they need.
HelpTip: from spec to Svelte¶
Build the tooltip once, as a component. It takes two props: the text, and a label for screen readers. Create src/lib/HelpTip.svelte:
<script>
let { text, label = 'Help' } = $props();
const id = $props.id(); // a unique id, to link button and bubble
let open = $state(false);
</script>
<span class="helptip" role="group"
onmouseenter={() => (open = true)} onmouseleave={() => (open = false)}>
<button type="button" aria-label={label} aria-describedby={id}
onfocus={() => (open = true)} onblur={() => (open = false)}
onkeydown={(e) => { if (e.key === 'Escape') open = false; }}>?</button>
<span class="bubble" role="tooltip" {id} hidden={!open}>{text}</span>
</span>
<style>
.helptip { position: relative; display: inline-block; }
button { width: 24px; height: 24px; padding: 0; border: 2px solid black;
border-radius: 50%; background: white; font-size: 14px; line-height: 1; }
.bubble { position: absolute; z-index: 10;
left: calc(100% + 8px); bottom: -4px; /* right of the button, growing upwards */
width: 440px; padding: 8px 10px;
border: 3px solid black; background: #ffe45c;
font-size: 14px; font-weight: 400; }
.bubble::before { content: ''; position: absolute; /* an invisible bridge over the 8px gap */
top: 0; bottom: 0; left: -11px; width: 8px; }
</style>
Use it next to a label:
<script>
import HelpTip from '#lib/HelpTip.svelte';
</script>
<label for="tags">Tags</label>
<HelpTip label="Help about tags"
text="Separate tags with commas, for example: home, school. NoteBox keeps at most 5 and writes them in lower case." />
<input id="tags" />
#lib or $lib?
SvelteKit 3 imports from your library folder with #lib/HelpTip.svelte. SvelteKit 2 uses $lib/HelpTip.svelte. If the build complains, use the one your project's version expects. $props.id() needs Svelte 5.20 or later.
Each line is there for a reason:
| Code | Why |
|---|---|
Hover handlers on the wrapper span |
The pointer can travel onto the bubble without closing it: that is hoverable |
.bubble::before |
The bubble sits 8px from the button. Without this invisible bridge, the pointer leaves the wrapper in the gap and the bubble closes on the way |
onfocus and onblur on the button |
Keyboard users get the same help |
Escape closes it, focus stays |
That is dismissible |
| No timer anywhere | That is persistent |
hidden={!open} instead of {#if open} |
aria-describedby needs something in the page to point to |
role="tooltip", aria-label, aria-describedby |
A screen reader says the button's name, then reads the bubble as its description |
The bubble grows upwards, so a field below the label stays visible. A field beside the button gets covered, and at 440px wide the bubble can run off a narrow window. Change left, bottom or the width until the field stays readable.
Before you call it finished¶
Hands off the mouse. Then do the same checks with the mouse:
| Check | How to test it in 10 seconds |
|---|---|
| Opens on hover and focus | Mouse over it. Then Tab to it without the mouse |
| Dismissible | Esc closes it while the pointer and the focus stay where they are |
| Closes when you leave | Tab away: the bubble goes |
| Hoverable | Move the pointer slowly from the button onto the bubble: it stays open |
| Persistent | Leave the pointer on it for ten seconds: it is still there |
| Does not cover its field | With the bubble open, you can still read and type in the field |
| Short | One or two sentences. No links or buttons inside |
| Useful | It says what testers got wrong, and does not repeat the label ("Name: enter the name") |
A guided tour¶
A guided tour is the beginners' set-up of the game: it walks you through your first turn. It is also the most expensive rung to keep alive, because every step points at a control that may move.
You do not write the dimmed overlay yourself. driver.js does the dimming, the positioning and the keyboard:
// src/lib/tour.js
import { driver } from 'driver.js';
import 'driver.js/dist/driver.css';
export function startTour() {
driver({
showProgress: true,
steps: [
{ element: '[data-testid="new-title"]',
popover: { title: 'Start here', description: 'Type a title and press Add. A note needs a title.' } },
{ element: '[data-testid="search"]',
popover: { title: 'Find a note', description: 'Search looks in titles and in tags.' } },
{ element: '[data-testid="editor"]',
popover: { title: 'Write', description: 'Everything is saved automatically. There is no Save button.' } },
],
}).drive();
}
Then one visible button on the screen:
<script>
import { startTour } from '#lib/tour.js';
</script>
<button onclick={startTour}>Take the tour</button>
Run it: you get Next, Previous, the progress "1 of 3", and Esc leaves at any step. The rules for a good tour:
- 3 or 4 steps. If you could tell a new user only three things, which? The rest is what the manual is for.
- A title of two or three words and one sentence. Say what to do and what happens: "Type here to filter by title or tag", not "This is the search box".
- Stable selectors. Point at controls with
data-testid, the attributes your Playwright tests already use. A selector like.btn:nth-child(3)breaks with the next layout change. - Started by the user. Always leave a way out.
Careful: do not start the tour automatically
A tour that jumps at you on every visit is the pop-up you close without reading. If you really must start it by itself, do it once, on the very first visit, and remember that you did.
A robot takes the photos¶
Your manual has thirty screenshots. Tomorrow you change the colour of one button. How many pictures are wrong now? All of them. Nobody retakes thirty pictures, so screenshots made by hand rot.
In Unit 4, Playwright took screenshots to compare them. Now the same tool takes them to publish them. The script is slower than doing it by hand once, and faster from the second change of the interface, for ever.
The first time on each PC, install it and its browser (a download of over 100 MB):
Then write scripts/screenshots.js. This one is for NoteBox, so change the names to match your app:
import { chromium } from '@playwright/test';
const OUT = 'docs/assets';
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1100, height: 640 }, deviceScaleFactor: 2 });
await page.goto('http://localhost:4173');
await page.evaluate(() => localStorage.clear()); // same data on every run
await page.reload();
await page.screenshot({ path: `${OUT}/notes-screen.png` }); // the whole window
await page.getByRole('button', { name: /Shopping list/ }).click();
await page.getByTestId('editor').screenshot({ path: `${OUT}/editor.png` }); // one element only
await page.getByRole('button', { name: 'Help about tags' }).focus(); // open the HelpTip
await page.getByTestId('editor').screenshot({ path: `${OUT}/tags-help.png` });
await browser.close();
Run it with two terminals, one for the app and one for the robot:
Three PNG files land in docs/assets/. Change the bubble's yellow in the CSS, rebuild, run the script again, and every picture updates itself. Add it to package.json as "docs:shots": "node scripts/screenshots.js" so nobody has to remember it.
deviceScaleFactor: 2 makes pictures twice as dense, so they stay sharp in the PDF. If the files get too heavy, use 1. Where Playwright's own browser cannot run, launch one you have installed: chromium.launch({ executablePath: '/path/to/chromium' }).
Conventions¶
A manual's pictures should look like one set:
- Always the same window size and the same data.
- Cropped to what you talk about. Use
locator.screenshotfor one element. - A caption under every picture.
- File names that say what the picture shows.
Then replace each placeholder in docs/user-manual.md and rebuild the site and the PDF:
The text in the brackets is the alt text a screen reader says on the site, and it becomes the caption in the PDF. So never leave it empty.
What usually goes wrong¶
| Symptom | Cause and fix |
|---|---|
net::ERR_CONNECTION_REFUSED, or a timeout |
The app is not running, or is on another port. Start the preview first (port 4173) |
| Different pictures on every run, and Git sees new files | The data changes. Reset the app's storage at the top of the script |
| The script clicks before the page is ready | Do not add pauses. Wait for the thing itself: await page.getByTestId('note-list').waitFor() |
| The HelpTip's bubble is cut off in the picture | An element screenshot shows only that element. Photograph a larger element, or the whole page |
| The image is missing on the published site | You wrote an absolute path (/assets/...), which points at the root of the server and breaks when the site lives in a subfolder. Use assets/... |
For a picture of a tour step, click the tour button, wait for .driver-popover, then take the screenshot.
Key takeaways¶
- Context-sensitive help appears on the screen, about the control you are using, at the moment you are using it.
- Climb the help ladder from the bottom: better label, inline help, tooltip, guided tour, link to the manual.
- Whatever the user cannot do without must be visible without hovering: rung 0 or 1.
- A tooltip is dismissible, hoverable and persistent, and opens on hover and on keyboard focus.
- A guided tour has 3 or 4 steps, is started by the user, and always has a way out.
- A Playwright script regenerates your screenshots with the same size and the same data, so the manual keeps up with the app.
Quick check¶
1. Which of these is context-sensitive help?
- A line under a field that explains the format it expects
- A chapter of the manual that lists every screen in order
- A video on the product's home page about its main features
- A PDF attached to the delivery with installation steps
Context-sensitive help is on the screen, about the control you are using, at that moment. The others are real documentation, but the user has to leave the task to find them.
2. A date of birth field only accepts the format DD/MM/YYYY. Where should that rule appear?
- In a line of inline help under the field, always visible
- In a tooltip on a small icon beside the field
- In a step of a guided tour of the whole screen
- In the reference guide on the documentation site
The user cannot finish the task without this rule, so it must be visible without hovering: rung 1, inline help. Tooltips are for detail most people do not need.
3. In a tooltip, what does hoverable mean?
- The pointer can move onto the bubble and it stays open
- The bubble opens as soon as the pointer is near it
- The bubble follows the pointer around on the screen
- The bubble closes again after a short time on its own
Hoverable comes from WCAG 1.4.13: the pointer can be moved over the extra content without it disappearing. People need that to read it, select its text or use a screen magnifier.
4. Why do HelpTip's mouse handlers go on the wrapper and not on the button?
- So that moving the pointer onto the bubble does not close it
- So that the bubble opens faster than it would on the button
- So that a screen reader can read the label of the button
- So that Svelte can generate a unique id for the bubble
With handlers on the button, the bubble closes as soon as the pointer leaves it, which breaks hoverable. The wrapper contains the button and the bubble, so the bubble counts as inside.
5. Why does a browser's title attribute not work as your app's tooltip?
- It appears only on mouse hover, never on keyboard focus or touch
- It only works on images and never on buttons or form fields
- It stays on the screen until the user reloads the whole page
- It is shown to screen readers but hidden from everybody else
The title tooltip is controlled by the browser: mouse only, after a delay, with no styling and no way to keep it open. Keyboard and touch users never see it.
6. What is the main advantage of a screenshot script over taking pictures by hand?
- It can regenerate every picture after each interface change
- It makes the pictures smaller than any hand-made picture
- It lets the manual work without any captions or alt text
- It stops the interface of the app from changing again
Hand-made screenshots rot: one changed button makes all of them wrong, and nobody retakes thirty pictures. A script produces the same size and the same data on every run.
7. Your screenshot script gives a slightly different picture each run, so Git sees changed images on every commit. What is the best fix?
- Reset the app's storage at the start of the script
- Add a long pause before each screenshot is taken
- Stop committing the images to the Git repository
- Take the screenshots by hand once a month instead
Pictures change because the data changes. Clearing the storage and reloading gives every run the same starting point. A pause would not make the data any different.
8. A photo-editing app shows a new user 14 pop-ups on every launch, one per tool. Which change best follows this page?
- Cut it to 3 or 4 steps, started from a button by the user
- Show the 14 pop-ups only on the first and second launch
- Replace the 14 pop-ups with 14 tooltips that open alone
- Add a Next button to the tour and keep all of the steps
A guided tour has 3 or 4 steps, says what to do, and is started by the user. The rest belongs in the manual. A tour that jumps out on its own is a pop-up people close without reading.
There are still things in the box you have not written: the page for whoever installs your app, the list of what it stores, and the first guided game. The next lesson, Admin manuals, data and tutorials, covers all three.