Sample project explainer
How the Opportunities Club
website works.
The Opportunities Club is a one-page website where students choose a topic (Study, Events, Tech, Creative or Volunteering) and see the matching clubs and events. This explainer walks through every file and every component, so you can read the code, change it with confidence, and reuse it in your capstone.
1. The files and what each one does
All of the code lives in term3/lesson-activities/lesson-3-opportunities-club/.
| File | Job | Lesson |
|---|---|---|
lesson1.html | The starting draft: a header with a nav, a heading and a short paragraph. No CSS file linked yet. | 1 |
lesson1.css | First styles for the draft: a blue nav bar, Flexbox for the menu and the Roboto font. | 2 |
index.html | The finished page: landmarks, the finder form, eight opportunity cards, About and Join sections. | 3 |
style.css | All the design and layout for index.html, written mobile-first in numbered sections. | 3 |
app.js | Shows or hides cards when a topic is chosen and writes a results message. | 4 |
README.md | Facilitator guide: which file fits which lesson block, extensions and the debug challenge answer key. | All |
Heads up: the cards point to pictures in an images/ folder (for example images/coding-club.svg). That folder is not in the repository yet, so the browser shows each picture’s alt text instead. The layout still works. Add your own images with the same file names, or change each src.
The README also mentions a debug-challenge.js handout and a v2-extended version; these are planned extras and are not in this folder yet.
2. How to open and run it
- Download or clone the repository, then open
index.htmlin any browser. There is nothing to install. - The Josefin Slab font loads from Google Fonts, so you need internet for it. Without internet the page falls back to
"Segoe UI",system-uiorsans-serif. - Open DevTools (F12 or right-click → Inspect) to use the Console, the Elements panel and the device toolbar.
- In CodePen: paste
index.html(only what is inside<body>),style.cssandapp.jsinto the three panels. Local images will not load there.
3. From first draft to finished page
lesson1.html is what a page might look like after Lesson 1. Comparing it with index.html shows the upgrades Lesson 3 teaches:
Draft (lesson1.html) | Finished (index.html) | Why it is better |
|---|---|---|
No lang, no charset, no viewport tag | <html lang="en">, <meta charset>, <meta name="viewport"> | Correct pronunciation for screen readers, correct characters, and phones stop pretending to be desktops. |
Heading inside <header>; no <main> | Header holds only the logo and nav; content sits in one <main> | Landmarks let assistive technology jump straight to the content. |
Links with empty href="" | Links to #finder, #about, #join | Each link now goes somewhere real on the page. |
Fixed padding: 20px, colours typed each time | Custom properties, rem, clamp() and min() | One change updates the whole site, and sizes adapt to the screen and the user’s font setting. |
| No skip link, no focus styles | Skip link and :focus-visible ring | Keyboard users can see where they are and skip the menu. |
4. The page skeleton
Before looking at any single part, look at the outline. Every visible area is inside a landmark: an element that tells the browser what that area is for.
<body>
<a class="skip-link" href="#main">Skip to main content</a> ← component 1
<header class="site-header"> logo + <nav> </header> ← component 2
<main id="main">
<div class="hero"> h1 + lead </div> ← component 3
<section id="finder">
<form id="filter-form"> … </form> ← component 4
<p id="results-message" role="status"></p> ← component 5
<div id="cards" class="card-grid"> ← component 6
<article class="card"> … </article> × 8 ← component 7
</div>
</section>
<div class="container two-col section"> #about + #join </div> ← component 8
</main>
<footer class="site-footer"> … </footer> ← component 9
<script src="app.js" defer></script>
</body>
Two layout helpers appear all over the page:
.containersetswidth: min(100% - 2rem, 70rem)and centres itself withmargin-inline: auto. On a phone it is the full width minus a 1rem gap on each side; on a big screen it stops at 70rem..sectionadds vertical padding withclamp(2rem, 5vw, 3.5rem), so sections breathe more on bigger screens.
The <script> tag uses defer, which means “download now, but run after the HTML has been read”. That guarantees the form and cards exist when app.js looks for them.
5. The components, one by one
Component 1: Skip link
What it is: the very first link on the page, “Skip to main content”. Why: keyboard users would otherwise have to Tab through every menu link on every page.
.skip-link { position: absolute; left: 1rem; top: -4rem; z-index: 10; … }
.skip-link:focus { top: 1rem; }
It is parked above the top of the screen (top: -4rem). When it receives focus, it slides into view. Its href="#main" matches <main id="main">. Try it: reload the page and press Tab once.
Component 2: Header and navigation
HTML: a <header> containing the logo text and a <nav aria-label="Main"> with a list of three links. A list is used because a menu is a list of choices, and screen readers announce “list, 3 items”.
CSS (Flexbox):
.header-inner { display: flex; flex-direction: column; align-items: center; gap: 0.5rem 2rem; }
.site-nav ul { display: flex; flex-wrap: wrap; justify-content: center; gap: 0.25rem 1.25rem; list-style: none; }
.site-nav a { display: inline-block; padding: 0.6rem 0.25rem; } /* bigger tap target */
@media (min-width: 40rem) {
.header-inner { flex-direction: row; justify-content: space-between; }
}
On phones the logo sits above the menu. Once the screen is at least 40rem wide, the media query switches to a row: logo on the left, menu on the right. Because the header is dark blue, .site-header :focus-visible changes the focus ring to yellow so it stays visible.
Component 3: Hero
HTML: the page’s only <h1> and a lead paragraph that says what the site is for.
.hero h1 { max-width: 18ch; font-size: clamp(2rem, 1.2rem + 4vw, 3.5rem); }
.lead { max-width: 38rem; font-size: clamp(1.0625rem, 1rem + 0.5vw, 1.25rem); }
clamp(min, ideal, max) makes the heading grow smoothly with the screen but never smaller than 2rem or larger than 3.5rem. 18ch is about 18 characters wide, which keeps the big heading to a few short lines.
Component 4: Finder form
HTML: a <form> with one labelled <select> and a submit <button>.
<label for="topic">matches<select id="topic">. Clicking the label focuses the dropdown, and screen readers read the label aloud.- Each
<option value="…">is a topic. Thevalue(for exampletech) is what JavaScript reads; the text (“Tech”) is what people see. - The “Volunteering” option has no cards on purpose, so you can see the empty-results message.
type="submit"means both clicking the button and pressing Enter submit the form.
CSS (Flexbox with wrapping):
.filter-form { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 1rem; }
.field { display: flex; flex-direction: column; flex: 1 1 14rem; }
select { min-height: 2.75rem; font: inherit; border: 2px solid var(--field-border); }
flex: 1 1 14rem reads as “grow, shrink, and aim for 14rem”. When there is room the dropdown and button share a row; when there is not, the button wraps underneath. font: inherit is needed because form controls ignore the page font by default. min-height: 2.75rem (about 44px) gives a comfortable touch target.
Component 5: Status message
<p id="results-message" class="status" role="status"></p>
It starts empty; app.js writes text into it, such as “Showing 2 of 8 opportunities.” role="status" tells screen readers to announce the change politely, without interrupting. In CSS, min-height: 1.6em keeps a line of space reserved so the cards below do not jump when the text appears.
Component 6: Card grid
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
gap: 1rem;
}
Read it from the inside out:
min(100%, 20rem): the smallest a column may be is 20rem, unless the screen is narrower than that, in which case it is 100%.minmax(…, 1fr): a column can be anywhere from that minimum up to one equal share of the space.repeat(auto-fit, …): make as many columns as will fit.
Result: one column on a phone, two on a tablet, three or more on a laptop, with no media query at all.
Component 7: Card (the reusable part)
<article class="card" data-category="tech">
<img src="images/coding-club.svg" alt="Two students beside a laptop showing lines of code" width="400" height="240">
<div class="card-body">
<div class="card-meta"><span class="chip">Tech</span></div>
<h3>Coding club</h3>
<p>Build small programs and websites with a friend.</p>
<p class="card-when">Thursdays, after classes</p>
</div>
</article>
<article>- Each card makes sense on its own, which is exactly what
articlemeans. data-category="tech"- A custom data attribute. CSS uses it to pick the card’s colours, and JavaScript uses it to decide whether to show the card. It must match an
<option value>exactly, including lowercase letters. <img alt width height>- The alt text describes the picture for anyone who cannot see it.
widthandheightlet the browser reserve space before the image loads, so the page does not jump. .card-body- Holds all the text, so the picture can reach the card’s edges while the words keep their padding.
.chip- The small rounded topic label.
border-radius: 999pxmakes a pill shape. <h3>- Headings stay in order: h1 (page) → h2 (Opportunity finder) → h3 (each card).
.card-whenmargin-top: autopushes the “when” line to the bottom of the card, so every card in a row lines up.
CSS highlights:
.card { --accent: var(--line); --accent-tint: var(--paper);
display: flex; flex-direction: column; border-radius: 0.75rem; overflow: hidden; }
.card-body { display: flex; flex: 1; flex-direction: column; gap: 0.5rem; padding: 1.25rem; }
.card[data-category="tech"] { --accent: var(--tech); --accent-tint: var(--tech-tint); }
.chip { background: var(--accent-tint); border-radius: 999px; }
Each card sets its own local custom properties. The attribute selector .card[data-category="tech"] swaps in the Tech colours, and the chip and image background use them automatically. overflow: hidden clips the picture to the rounded corners.
Component 8: About and Join
Two <section> elements inside a .two-col wrapper. Each section uses aria-labelledby pointing at its own <h2>, which gives the region a name. The joining steps are an <ol> because they happen in order.
.two-col { display: grid; gap: 2rem; } /* one column on phones */
@media (min-width: 600px) {
.two-col { grid-template-columns: 1fr 1fr; } /* two equal columns */
}
.join-box { padding: 1.5rem; background: var(--study-tint); border-radius: 0.75rem; }
Component 9: Footer
A <footer> landmark with one line of text, centred, with a light top border. Simple on purpose: this is where contact links or credits would go on a real site.
6. style.css, section by section
The file is mobile-first: the normal rules describe the phone layout, and @media (min-width: …) blocks add changes for bigger screens. The numbered comments in the file match this list.
| # | Section | Key ideas |
|---|---|---|
| 0 | Fonts | Josefin Slab is loaded by <link> tags in the HTML head, with fallbacks listed in font-family. |
| 1 | Design tokens | :root custom properties such as --ink, --focus, and a colour plus tint for each category. Change one here and it changes everywhere. |
| 2 | Base styles | box-sizing: border-box; body font in rem; img { max-width: 100%; height: auto }; [hidden] { display: none !important }; long links wrap with overflow-wrap: anywhere. |
| 3 | Accessibility helpers | The skip link and a :focus-visible outline. Never remove outlines without replacing them. |
| 4 | Layout helpers | .container with min(), .section with clamp(). |
| 5 | Header + nav | Flexbox column that becomes a row at 40rem. |
| 6 | Hero | Fluid heading with clamp() and a ch line length. |
| 7 | Finder form | Wrapping Flexbox, labelled fields, a 2.75rem tall button, and the .status line. |
| 8 | Cards + images | auto-fit Grid, flex-column cards, attribute selectors for category colours, chips. |
| 9 | About / Join | Grid that becomes two columns at 600px. |
| 10 | Footer | Centred text with a top border. |
| 11 | Motion preferences | Smooth scrolling only inside @media (prefers-reduced-motion: no-preference), so people who turn off animations are respected. |
Why [hidden] needs !important: the browser hides hidden elements with display: none, but .card { display: flex } is more specific and would win, so hidden cards would still show. The !important rule makes “hidden” always mean hidden, which is what app.js relies on.
A few classes, such as .button--quiet, .chip--featured and input[type="search"], are styled but not used on this page yet. They are ready for extensions like a search box or a “Featured” label.
7. app.js, step by step
The cards are already written in HTML, so the page still works if JavaScript fails. The script has one job: show or hide cards when someone picks a topic.
Step 1: Find the elements (variables)
const form = document.querySelector("#filter-form");
const topicSelect = document.querySelector("#topic");
const message = document.querySelector("#results-message");
const cards = document.querySelectorAll(".card");
querySelector finds the first element that matches a CSS selector; querySelectorAll finds all of them and returns a list. const is used because these references never change.
Step 2: A function with parameters and a return value
function cardMatches(card, topic) {
if (topic === "all") {
return true;
}
return card.dataset.category === topic;
}
Inputs: one card and a topic. Output: true (show it) or false (hide it). data-category in HTML is read as card.dataset.category in JavaScript. === checks that two values are exactly the same, so "Tech" and "tech" do not match.
Step 3: A loop and a decision
function showTopic(topic) {
let visibleCount = 0;
for (const card of cards) {
if (cardMatches(card, topic)) {
card.hidden = false;
visibleCount = visibleCount + 1;
} else {
card.hidden = true;
}
}
if (visibleCount === 0) {
message.textContent = "No opportunities in this topic yet. Try another topic!";
} else {
message.textContent = "Showing " + visibleCount + " of " + cards.length + " opportunities.";
}
}
let is used for visibleCount because it changes. for…of runs the block once for each card. Setting card.hidden adds or removes the HTML hidden attribute. textContent writes plain text (safer than innerHTML because nothing is treated as code).
Step 4: Listen for the form (event)
form.addEventListener("submit", function (event) {
event.preventDefault();
showTopic(topicSelect.value);
});
A form normally reloads the page when submitted. preventDefault() stops that so the script can update the page instead. topicSelect.value is the value of the chosen option.
Step 5: Start
showTopic("all");
Runs once when the page loads, so every card is visible and the message already says “Showing 8 of 8 opportunities.”
8. What happens when you press “Show results”
- You choose “Tech” in the dropdown. Its value is
"tech". - You click the button (or press Enter). The form fires a
submitevent. - The listener stops the page reload and calls
showTopic("tech"). showTopicloops over all 8 cards and askscardMatches(card, "tech")for each one.- The 2 Tech cards get
hidden = false; the other 6 gethidden = true. CSS[hidden]removes them from the layout. - The grid reflows the remaining cards automatically.
- The status message becomes “Showing 2 of 8 opportunities.” and screen readers announce it.
9. Accessibility checklist
Every item below is already built into the page. Use it as a checklist for your own project.
- Language and viewport:
lang="en"and the viewport meta tag. - Landmarks: one header, one nav with a name, one main, one footer.
- Skip link to the main content.
- Heading order: h1 → h2 → h3 with no skipped levels.
- Labels: every form control has a matching
<label>. - Alt text on every image that describes what is shown.
- Visible focus with
:focus-visible, adjusted for the dark header. - Live updates announced with
role="status". - Touch targets at least 2.75rem tall.
- Relative units (
rem) so the page respects the user’s font size. - Reduced motion respected for smooth scrolling.
Test it: use only the keyboard (Tab, Shift+Tab, Enter, arrow keys in the dropdown); zoom to 200%; set the device toolbar to 320px wide.
10. Adapting it for your capstone
- Change a card: copy a whole
<article class="card">, then change the heading, text, image, alt text anddata-category. - Add a topic: add an
<option value="yourtopic">to the dropdown, then add a.card[data-category="yourtopic"]rule instyle.csswith two new colour tokens in:root. - No JavaScript changes needed:
app.jsworks with any number of cards and any topic names.
The same pattern fits all three capstones:
- School Opportunities Hub: clubs, competitions and events, filtered by type.
- Kenya Weather Dashboard: one card per town, filtered by region.
- CyberSmart Quest: one card per scenario, filtered by topic such as passwords or scams.
Stretch goal: move the card content into a JavaScript array of objects and build the cards from the data. That is the Lesson 4 “turn capstone information into structured data” objective.
11. Common mistakes and how to fix them
| What you see | Likely cause | Fix |
|---|---|---|
| A card never appears for its topic | data-category and <option value> differ (event vs events, or Tech vs tech) | Make them exactly the same, all lowercase. |
| A new card has no colour | No .card[data-category="…"] rule for that category | Add the rule and its colour tokens. |
| Page reloads and the filter resets | event.preventDefault() missing | Put it back as the first line of the listener. |
Nothing happens; Console shows Cannot read properties of null | An id in querySelector does not match the HTML, or defer was removed | Check spelling of ids and keep defer on the script tag. |
| Hidden cards still show | The [hidden] rule was deleted | Restore [hidden] { display: none !important; }. |
| Broken image icon | The file is not in images/ or the name is misspelled | Check the path; the alt text will show until it is fixed. |
| Page scrolls sideways on a phone | A fixed width or missing box-sizing | Use max-width, min() and keep the box-sizing rule. |
12. Facilitator notes
| Lesson block | Use |
|---|---|
| L3 Connect (0–10) | Open index.html and resize the window. Ask what moves, wraps or shrinks. |
| L3 Learn (10–30) | style.css sections 5, 7 and 8, and the two @media blocks. Component tour slides 5–11. |
| L3 Guided build (30–50) | Landmarks, the labelled form and one card with alt text. Slides 3, 7 and 10. |
| L3 DevTools (50–60) | Grid overlay on .card-grid, inspect .card-body padding, device toolbar at 320px. |
| L4 Learn (10–30) | app.js steps 1–3. Slides 13–14. |
| L4 Guided practice (30–50) | Step 4 and the “Try it yourself” list at the bottom of app.js. Slide 15. |
| Capstone checkpoint | Teams copy a card and change the content and category. Slide 16. |
- Ask students to write their own alt text. Test: “If the picture vanished, would this sentence tell you what was there?”
- All content is fictional and the page collects no personal information.
- The full lesson mapping and debug challenge answer key are in the activity README.
13. Glossary
- Component
- A self-contained part of a page, such as the header, a form or a card, with its own HTML and CSS.
- Landmark
- An element such as
header,nav,mainorfooterthat tells assistive technology what an area is for. - Mobile-first
- Writing the small-screen styles first, then adding
min-widthmedia queries for bigger screens. - Media query
- A CSS block, like
@media (min-width: 40rem), whose rules only apply when the condition is true. - Flexbox
- A one-direction layout (a row or a column) for lining items up and spacing them.
- Grid
- A two-direction layout of rows and columns.
- Custom property
- A CSS variable such as
--focus, used withvar(--focus). rem- A size relative to the root font size, which follows the user’s browser setting.
clamp()/min()- CSS functions that pick a size within limits, so things resize smoothly.
data-*attribute- Your own named attribute for storing information on an element, read in JavaScript through
dataset. - Event listener
- Code that waits for something to happen, such as a form submit, then runs a function.
- Parameter / return value
- The inputs a function receives, and the answer it gives back.