Overview#
Academy is a friendly website for an online school, built as a WordPress block theme. The demo
brand is Northfield Academy, a fictional school that teaches design, code and data. The theme
has a home page with a course search, a course catalogue with search, filters, sorting and a grid
or list view kept in the URL, course pages generated from one data file (curriculum accordion with
free preview lessons, reviews histogram and a sticky enrol card), learning paths, instructor
profiles, pricing with a monthly/yearly switch, a comparison table and a team seats calculator, a
student dashboard with progress rings, a learning streak, "continue where you left off" and
certificates, a lesson player with a course sidebar, mark as complete, notes and tabs, and a 404
page, with light and dark modes. It is version 1.0.0 (see template.json and style.css), works
with WordPress 7.1 and PHP 7.4 or newer, and is the WordPress version of the HTML template of the
same name. See it live at freeframe.dev/templates/academy.
Everything you see is made of core WordPress blocks arranged in block patterns, so you edit text,
links, buttons, lists and images in the block editor without touching code. The course, instructor,
lesson and dashboard pages are the exception: their content is generated in the browser from the
course data in assets/js/main.js, for the course, instructor or lesson chosen in the URL. Colours,
the fonts and font sizes are design tokens in theme.json: change them in Appearance > Editor >
Styles and the whole site follows.
All courses, instructors, prices, reviews and learner stories are placeholder content. Enrolment, progress and notes are saved only in the visitor's browser: there are no accounts and no payments.
Quick start#
Requirements: WordPress 7.1 or newer and PHP 7.4 or newer. No plugins are needed.
- Download
academy.zipfrom freeframe.dev/templates/academy (the WordPress tab). - In your WordPress admin go to Appearance > Themes > Add New Theme > Upload Theme, choose the zip and click Install Now, then Activate. The theme is listed as Freeframe Academy.
- Go to Appearance > Academy setup and click Create the demo pages. This creates the Home, Courses, Course, Learning paths, Instructors, Instructor profile, Pricing, My learning and Lesson player pages from the theme's page patterns, sets Home as the front page (Settings > Reading) and creates the header menu. Pages that already exist with the same slug are left alone, so it is safe to click it again.
- Set the site title to your school's name in Settings > General (the demo uses "Northfield Academy" with the tagline "Learn the skills that build the web"), then edit any page in Pages, and the header, footer and templates in Appearance > Editor.
You can also try the theme without installing anything: the Try it in WordPress button on freeframe.dev opens it in WordPress Playground (WordPress running in your browser) with the demo pages already set up.
To work on the theme's files locally you need Node.js 24 and pnpm; WordPress itself runs in WordPress Playground, so there is no PHP, web server or database to install:
pnpm install
pnpm dev # builds the CSS, then starts WordPress with the theme at http://127.0.0.1:9400pnpm dev signs you in as admin (password password) and creates the demo pages. The site is
rebuilt from scratch every time you start it; your edits to theme files are live, edits made in the
WordPress admin are not kept.
Pages#
Each page is a page pattern (in Patterns > Academy pages) made of section patterns. Every section is also offered on its own in the block inserter under Patterns > Academy.
| Page | Slug | Page pattern | Sections (pattern files in patterns/) |
|---|---|---|---|
| Home | / |
page-home.php |
home-hero (course search and illustration), logos, topics (topic tiles with live course counts), featured-courses, how-it-works, home-paths, outcomes (count-up numbers), home-instructors, learner-stories, plans-teaser |
| Courses | /courses/ |
page-courses.php |
courses-hero (search), catalogue (filters, sort, grid/list view, result count, empty state) |
| Course | /course/?id=react-from-scratch |
page-course.php |
course-header, course-details (outcomes, curriculum accordion, instructor, reviews histogram, FAQ, sticky enrol card), related-courses, preview-dialog |
| Learning paths | /paths/ |
page-paths.php |
paths-hero, paths-list (each path's courses in order, hours, weeks and bundle price), paths-cta |
| Instructors | /instructors/ |
page-instructors.php |
instructors-hero, instructors-grid, teach ("Teach with us" form, #teach) |
| Instructor profile | /instructor/?id=hana |
page-instructor.php |
instructor-profile (bio, stats, tags), instructor-courses, other-instructors |
| Pricing | /pricing/ |
page-pricing.php |
pricing-hero (billing switch), plans, team-seats (seats calculator and team quote form, #teams), compare-plans, pricing-faq (#faq) |
| My learning | /learn/ |
page-learn.php |
learn-dashboard (stats, streak, continue learning), learn-enrolled (progress rings), learn-certificates, learn-recommended (and the reset button), certificate-dialog |
| Lesson player | /lesson/?course=sql-essentials&lesson=m1-l1 |
page-lesson.php |
lesson-player (sidebar, video area, mark as complete, Overview/Notes/Resources tabs, previous/next) |
| 404 | any missing URL | templates/404.html |
404 (illustration and course search), 404-suggested |
The Course, Instructor profile and Lesson player pages each show any course, instructor or lesson:
the script reads ?id= (or ?course= and &lesson=) and fills the page. An unknown or missing id
falls back to the first course, instructor or lesson.
Posts use templates/single.html; the blog, archives and search results use templates/index.html,
templates/archive.html and templates/search.html.
Project structure#
freeframe-academy/
style.css theme header (name, version, requirements, license); no styles
theme.json design tokens: palette, fonts, font sizes, spacing, layout widths, button
functions.php loads the files in inc/
inc/setup.php stylesheet and script, editor styles, the dark mode and "js" class
script, script strings and page URLs, browser bar colour and favicon,
title separator, pattern categories
inc/icons.php registers assets/icons/*.svg for the Icon block
inc/icon-labels.php the icons' names shown in the editor
inc/block-styles.php button block styles
inc/navigation.php current-page markers (aria-current) in the header
inc/site-setup.php Appearance > Academy setup: creates the demo pages and the menu
templates/ page.html (pages), page-with-title.html, single.html, index.html,
archive.html, search.html, 404.html
parts/ header.html and footer.html (they show the header and footer patterns)
patterns/ every section and page (PHP files with block markup)
assets/css/theme.css the compiled stylesheet (do not edit: built from src/)
assets/css/editor.css the same for the block editor (generated by src/editor-css.mjs)
assets/js/main.js the site's JavaScript: the block adapter, the course data (TOPICS,
INSTRUCTORS, COURSES, PATHS), generated artwork, progress, header,
reveals, forms and one function per page
assets/js/editor.js block editor tweaks
assets/icons/ SVG icons for the Icon block
assets/img/ logo, favicon, social image
assets/fonts/ the Plus Jakarta Sans and Caveat font files and their licenses (OFL.txt)
src/theme.css the Tailwind CSS source of assets/css/theme.css
src/icons.css icons drawn before or after text with CSS (generated)
src/editor-css.mjs writes assets/css/editor.css after each build
languages/ translations of the theme (see Translations)
blueprint.json WordPress Playground setup used by `pnpm dev`
screenshot.png the picture shown in Appearance > Themes
package.json the CSS build and the local WordPress (Node.js tooling)
readme.txt, README.md, DOCS.md, CHANGELOG.md, LICENSE.txtCustomising#
Brand and colours#
The colours are in theme.json (settings.color.palette) and in Appearance > Editor > Styles >
Colors > Palette:
| Colour | Slug | Default | Used for |
|---|---|---|---|
| Base (paper) | base |
#fbfaf4 |
the page background; switches to Night in dark mode |
| Contrast (ink) | contrast |
#10241b |
body text; switches to white in dark mode |
| Leaf 50 to Leaf 950 | leaf-50 ... leaf-950 |
#effbf2 ... #082716 (Leaf 700 is #19693a) |
the brand green: buttons, links, eyebrows, progress, focus rings |
| Sun 100 to Sun 500 | sun-100 ... sun-500 |
#fff4c7 ... #f5a70b |
the yellow accent: badges, the streak, the back-to-top button, highlights |
| Ink | ink |
#10241b |
dark text and the footer background |
| Paper | paper |
#fbfaf4 |
the light background (header, mobile menu) |
| Night, Night 2, Night 3 | night, night-2, night-3 |
#0a1510, #11211a, #1a2e25 |
dark mode backgrounds: the page, cards, form fields |
| White, Black | white, black |
plain colours for your own blocks |
The theme's stylesheet reads these colours from the palette (--color-leaf-700: var(--wp--preset--color--leaf-700) and so on in src/theme.css), so changing Leaf 700 in Styles
recolours every primary button at once. Change the whole Leaf scale together (light 50 to dark 950):
the hover states use the next shade and dark mode uses the light shades (leaf-300, leaf-400). In
theme.json:
{ "slug": "leaf-700", "name": "Leaf 700", "color": "#1d4ed8" }A few colours are written into files rather than read from the palette: the browser bar colours
(<meta name="theme-color">, #fbfaf4 and #0a1510, in freeframe_academy_head_meta() in
inc/setup.php), the topic colours of the course covers, portraits and path cards (TOPICS in
assets/js/main.js) and the topic label colours next to them (TOPIC_TEXT, chosen for AA contrast
in light and dark), the certificate artwork in certificate() (#19693a and #ffc727), the topic
tiles' icon backgrounds on the home page (classes such as [background:#ffe1d6]), the footer
background in dark mode (dark:bg-[#07100c]), the home hero and 404 illustrations (inline SVG in
Custom HTML blocks) and the logo (logo.svg, favicon.svg).
Typography#
Text uses Plus Jakarta Sans (variable, weights 400 to 800) and the handwritten accents (class
hand, such as "welcome back, Alex") use Caveat (bold), bundled in assets/fonts/ and declared
in theme.json (settings.typography.fontFamilies), so no request goes to Google. To use other
fonts, install them in Appearance > Editor > Styles > Typography > Fonts (the Font Library) and
select them, or replace the fontFace entries in theme.json; the stylesheet reads
--wp--preset--font-family--plus-jakarta-sans and --wp--preset--font-family--caveat, so rename
those slugs only together with src/theme.css. The certificate SVG names its fonts directly
(font-family="Plus Jakarta Sans, sans-serif" and "Caveat, cursive" in certificate()).
Font sizes are the presets in theme.json (xs 0.75rem to 9xl 8rem, the Tailwind scale). The
stylesheet uses them too: changing Large in Styles changes every text-lg in the design.
Content#
Edit pages in Pages: click any text to change it, select a button to change its link, select an image and use Replace. Sections are groups; open List View (the stacked lines icon at the top left) to select a section, move it, duplicate it or delete it. To add a section from another page, use the block inserter (+) > Patterns > Academy.
The hero, topic tiles, how it works, outcomes, learner stories, plan teaser, page headers, the Free
plan card, the paths call to action and the pricing questions are ordinary blocks. The questions on
the Pricing page are Details blocks with the class acc: the summary line holds the question,
the inside holds the answer. To add one, duplicate a Details block in List View.
Some content is in Custom HTML blocks, because blocks can't express it or a script fills it: edit those in the block's HTML view.
| Pattern | Custom HTML | What it is |
|---|---|---|
course-header, course-details, related-courses, preview-dialog |
everything | the Course page, filled from the course in the URL (see Course page) |
instructor-profile, instructor-courses, other-instructors |
everything | the Instructor profile page (see Instructor profiles) |
learn-dashboard, learn-enrolled, learn-certificates, learn-recommended, certificate-dialog |
everything | the My learning page (see Student dashboard) |
lesson-player |
everything | the Lesson player page (see Lesson player) |
featured-courses, home-paths, home-instructors |
the card grids | filled by the script from the data |
paths-list, instructors-grid, 404-suggested |
the list | filled by the script from the data |
home-hero, 404 |
the search form, the illustration | see Forms |
courses-hero, catalogue |
the search box; the filters, toolbar and results | see Course catalogue |
pricing-hero, plans |
the billing switch; the Plus and Teams plan cards | see Pricing |
team-seats, compare-plans |
the calculator and team quote form; the comparison table | see Pricing |
teach |
the application form and its success message | see Forms |
footer |
the newsletter form | see Forms |
header |
the theme switch and the menu button | buttons driven by assets/js/main.js |
Because the Course, Instructor profile, My learning and Lesson player pages are made entirely of Custom HTML blocks, there is little to edit on them in the editor: their text comes from the data (below) and from the labels in the HTML ("What you’ll learn", "Curriculum", the FAQ). The course page's FAQ is the same for every course.
Blocks can't store data- attributes, so a few blocks carry a class (Block settings > Advanced >
Additional CSS class(es)) that the script reads. The first part of assets/js/main.js (marked
"WordPress: blocks → the data attributes this script expects") turns each class ffd-<name> into
data-<name> and ffd-<name>--<value> into data-<name>="<value>" before the rest of the script
runs (an underscore in a value stands for a space). In Academy these are the count-up numbers in the
outcomes section: ffd-count--180000 ffd-suffix--+ becomes data-count="180000" data-suffix="+"
(see Scroll reveals and counters). Other classes the theme reads:
revealfades a block in as it scrolls into view (reveal-delay-1toreveal-delay-3stagger it).link-cardon a group makes the whole group clickable: the first link inside it stretches over the group (the logo, the topic tiles).ff-mobile-onlyon a menu link hides it in the desktop menu (see Navigation, header and footer).ff-hiddenon the mobile menu keeps it hidden until the menu button opens it.
The page's features are chosen by what it contains. The adapter names the page (the HTML version's
<body data-page>) from the first of these elements it finds: [data-featured-courses] (home),
[data-catalogue] (courses), [data-curriculum] (course), [data-paths] (paths),
[data-instructors] (instructors), [data-instructor-courses] (instructor), [data-billing-toggle]
(pricing), [data-enrolled] (learn), [data-side-curriculum] (lesson), [data-suggested] (404).
Keep each of these sections on its own page: adding the Featured courses section to the Pricing page,
for example, would make the script treat it as the home page and the calculator would stop working.
The school's name appears as text in the header ("Northfield" / "Academy" next to the logo), the footer (logo, copyright line) and the script (certificates). The header and footer are edited once in their patterns; the browser tab titles of the course, instructor and lesson pages use the site title (see SEO and sharing).
Courses, instructors and paths#
Everything about courses comes from the data near the top of assets/js/main.js (section "Data:
topics, instructors, courses and learning paths"). It is the same data as the HTML version's:
| Constant | What it holds |
|---|---|
TOPICS |
the five topics (design, frontend, backend, data, product) with name and the art colours bg, fg, deep, alt |
TOPIC_TEXT |
the topic label's text colour classes, light and dark |
INSTRUCTORS |
id, name, role, topic, portrait colours (skin, hair) and hair style (0 to 5), rating, students, bio, tags |
COURSES |
id, title, topic, level, hours, price (0 = free), rating, reviews, students, instructor (an instructor id), optional badge, updated, summary, outcomes, modules, optional lessons |
PATHS |
id, name, topic, weeks, blurb, courses (course ids, in order) |
LESSON_PATTERN |
the five lesson names used when a course has no lessons |
Every page reads this data: the catalogue, the Course page, the paths, the instructors, the
dashboard, the lesson player and the course cards on the home and 404 pages. Make changes to
assets/js/main.js in a child theme so a theme update doesn't undo them (see
Child themes).
To add a course:
-
Add an object to
COURSES:js{ id: 'figma-basics', title: 'Figma for Beginners', topic: 'design', level: 'Beginner', hours: 4, price: 39, rating: 4.7, reviews: 120, students: 2400, instructor: 'amara', badge: 'New', updated: 'Oct 2026', summary: 'Learn frames, auto layout and components by designing a small app.', outcomes: ['Use frames and auto layout', 'Build reusable components'], modules: ['Getting started', 'Auto layout', 'Components', 'Prototyping'] },topicmust be a key ofTOPICSandinstructoranidinINSTRUCTORS.levelisBeginner,IntermediateorAdvanced(the catalogue's level filter compares these English values).badgeis shown on the card's cover (Bestseller,New;Freeis not shown, the price says it). -
Add its
title,summary,outcomes,modulesandlessonstexts tofreeframe_academy_script_strings()ininc/setup.phpif the site needs them translated (without them the English text is shown). A newbadgeorlevelvalue needs an entry there too. -
Update the text that counts the catalogue: "Twelve courses across design, frontend, backend, data and product." in the
courses-herosection and the starting count "12 courses" in thecatalogueCustom HTML block (the script replaces it as soon as it runs), and the numbers in the home page'soutcomessection if they should change.
The course then appears in the catalogue, at /course/?id=figma-basics, in search, on its
instructor's profile, in the related courses of courses that share its topic or instructor and, if
you add its id to a path's courses, in that path. No pattern needs to change. The home page's
featured courses are the ids listed at the start of pages.home; the 404 page suggests the first
three courses in COURSES.
How the curriculum works (function curriculum(course)):
- Each entry in
modulesbecomes a module with idsm1,m2, and so on. - If the course has a
lessonsarray (one array of lesson titles per module), those titles are used. Otherwise every module gets five lessons named fromLESSON_PATTERN("Why it matters", "Core ideas", "Guided walkthrough", "Hands-on exercise", "Recap and quiz") followed by the module title. - Lesson ids are
m<module>-l<lesson>, for examplem2-l3. - Lesson lengths are worked out from the course id so they add up to
hours(at least 4 minutes each), and never change between visits. - The first two lessons of the first module are free previews.
Lesson ids are also what saved progress refers to, so if you rename or reorder modules on a live site, existing visitors' progress for that course may point at different lessons (unknown ids are dropped, see Saved data).
To add an instructor, add an entry to INSTRUCTORS (topic must be a key of TOPICS; it sets the
portrait's colours; style picks one of six hairstyles) and add role, bio and tags to
freeframe_academy_script_strings(). Names are not translated. The instructor appears on the
Instructors page, at /instructor/?id=<id> and on their courses. The home page shows the first four
instructors in INSTRUCTORS.
To add a learning path, add an entry to PATHS with the course ids in order, and add its name and
blurb to freeframe_academy_script_strings(). Hours and the bundle price are added up from the
courses; weeks is your own estimate. Each path is a section with the anchor #<id> on the
Learning paths page (/paths/#data-analyst) and a card on the home page.
To add a topic, add it to TOPICS and TOPIC_TEXT, a checkbox with name="topic" and the new
value to the filters in the catalogue Custom HTML block, and a tile to the home page's topics
section (a group with link-card; the count is a <span data-topic-count="<topic>"> inside the
paragraph's link, so edit it with Edit as HTML). A topic without its own drawing in cover()
uses the product topic's sticky-note motif.
The dashboard's student name, Alex Morgan, is shown on certificates (translatable in
freeframe_academy_script_strings()), and the welcome line "welcome back, Alex" is in the
learn-dashboard Custom HTML block. Replace them with the signed-in user's name if you connect real
accounts.
Images and artwork#
There are no image files for courses or people: the course covers, instructor portraits, star
ratings and certificates are SVG drawn by functions in assets/js/main.js:
| Function | Draws | Notes |
|---|---|---|
cover(course) |
course cover art (320 × 200 viewBox, shown at 16:10) | colours from the course's topic; two alternating motifs per topic |
avatar(person, size) |
instructor portrait (square, circular crop) | uses the instructor's skin, hair and style |
stars(rating) |
star rating with partial fill | has a text alternative ("Rated 4.8 out of 5") |
certificate(course, name) |
certificate of completion (560 × 380) | shown on the dashboard and in its dialog |
To use photos, return an <img> from cover() (keep a 16:10 image, for example 1280 × 800) and
avatar() (square, at least 320 × 320), in a child theme's copy of main.js. Give the <img>
meaningful alt text, or alt="" when the image is decorative, as the cards do. Upload the photos
to the Media Library and build their URLs from the course or instructor id.
- Illustrations: the home hero's video player mock-up and the 404 drawing are inline SVG and
HTML in Custom HTML blocks, marked
aria-hidden="true". They are pictures, not content: to use your own, delete the Custom HTML block and insert an Image block. - Logo: the header and footer show
assets/img/logo.svgin an Image block next to a paragraph with a link (labelled "Northfield Academy home" for screen readers). Edit both in Appearance > Editor > Patterns > Header (and Footer). - Icons: the topic icons and the learner stories' quote marks are Icon blocks. Select one and
pick another icon from the Academy collection (arrow right, chart, check, code, compass,
palette, quote, server) or WordPress's own. The arrow after buttons such as "Browse all courses"
is drawn by the class
icon-after-arrow-right. - Social image:
assets/img/og-image.png(1200 × 630, sourceog-image.svg). WordPress doesn't output Open Graph tags by itself; see SEO and sharing. - Favicon:
assets/img/favicon.svgis used until you set a Site Icon in Settings > General.
Navigation, header and footer#
The header and footer are template parts (parts/header.html, parts/footer.html) showing the
header and footer patterns. Edit them in Appearance > Editor > Patterns > Header / Footer.
The header has two Navigation blocks showing the same menu: the desktop one (class site-nav) and
the one in the mobile menu (site-nav-mobile). Edit the menu once in Appearance > Editor >
Navigation (the setup creates "Main menu" with Courses, Paths, Instructors, Pricing and My
learning). The My learning link in the menu has the class ff-mobile-only: it is hidden in the
desktop menu, because the desktop header has its own "My learning" link (a paragraph with a link,
class nav-link) next to the theme switch. A Navigation block that doesn't point at a menu shows the
most recently created one; to pin a menu, select the Navigation block and choose it in the block
settings, for both blocks. The "Join for free" buttons (header and mobile menu) are Button blocks.
WordPress marks the menu link of the page being viewed (aria-current="page"). The theme also marks
(freeframe_academy_header_current() in inc/navigation.php):
- the header's "My learning" link on the My learning page,
- the Courses menu link on the Course page and the Instructors menu link on the Instructor profile
page, as in the HTML version. Which page belongs to which menu link is the
freeframe_academy_menu_parentsfilter (page slug => menu page slug):
add_filter( 'freeframe_academy_menu_parents', function ( $parents ) {
$parents['lesson'] = 'learn'; // keep "My learning" current in the menu on the lesson player
return $parents;
} );The footer has the logo, three link columns (List blocks: click a link to change it, press Enter to
add one), the newsletter form and the copyright line. The links to /courses/, /learn/ and so on
are written with the demo slugs, and the script builds its links from the page slugs (see
Page addresses): if you rename a page, update the links that point to it.
Dark mode#
A small script printed first in <head> (freeframe_academy_theme_script() in inc/setup.php)
applies the saved theme before the page paints, so there is no flash. It adds the dark class to
<html> when localStorage key theme is "dark", or when nothing is saved and the visitor's
system prefers dark. The theme switch in the header ([data-theme-toggle], a Custom HTML block)
toggles the class, saves "dark" or "light" and updates its aria-label and aria-pressed.
In the stylesheet, the dark: utilities follow that class (@custom-variant dark in
src/theme.css), and html.dark switches the Base and Contrast presets to Night and white, so
blocks that use those colours follow the theme too. When you add colours to your own blocks, check
them in both modes.
To always show the light theme, remove the theme switch from the Header pattern and add this to a
child theme's functions.php:
add_filter( 'freeframe_academy_dark_mode', '__return_false' );To default to light but keep the switch, replace the script with one that ignores the system setting:
add_action( 'after_setup_theme', function () {
remove_action( 'wp_head', 'freeframe_academy_theme_script', 0 );
} );
add_action( 'wp_head', function () {
wp_print_inline_script_tag( "(function(){var d=document.documentElement;d.classList.add('js');try{if(localStorage.getItem('theme')==='dark')d.classList.add('dark');}catch(e){}})();" );
}, 0 );Keep the js class: the scroll reveals rely on it.
Features#
Course catalogue#
On the Courses page, function pages.courses in assets/js/main.js. The filters, toolbar and
results are one Custom HTML block in the catalogue section (<form data-filters> in an <aside>,
the result count [data-result-count], the sort <select id="sort">, the view buttons
[data-view], the grid [data-catalogue] and the empty state [data-empty]); the search box is the
form [data-search-catalogue] with the input #catalogue-search in the courses-hero section.
- Search matches the course title, summary, topic name and instructor name, as you type.
- Filters: topic and level (checkboxes, any number), length (
shortunder 5 hours,medium5 to 10,longover 10), price (free,paid) and minimum rating (4.8,4.7). The "Filters" button shows how many are active. - Sort:
popular(most students, the default),rating,newest(order inCOURSES, last first),length,price-asc,price-desc. - The state is written to the URL with
history.replaceState, so searches can be shared and other pages can link to a filtered catalogue: the home page's topic tiles link to/courses/?topic=design. Supported parameters:q,topicandlevel(comma-separated),length,price,rating,sort. Unknown values are ignored. - Grid or list view is saved in
localStorage(academy-view). - "Clear filters" (
[data-clear-filters], in the toolbar and the empty state) resets everything. - On small screens the filters are behind a "Filters" button (
[data-filters-toggle], witharia-expanded) that shows#filters-panel.
The result count is a polite live region, so screen readers hear "5 courses" after each change. To
add a filter value, add the input in the catalogue Custom HTML block and the matching condition in
the apply() function.
Course page#
On the Course page (/course/?id=<course id>), function pages.course.
- Elements with
data-bind="…"are filled from the course (title, summary, topic, level, rating, reviews, students, hours, lessons, modules, updated, instructor, instructor role and bio, price, and the enrol button's text), and links withdata-bind-hrefpoint at the instructor's profile and the topic in the catalogue. The browser tab title is set to the course title. - The curriculum (
[data-curriculum]) is native<details>elements, so it works with the keyboard and screen readers without extra code; the first module is open. "Expand all" ([data-expand-all]) opens or closes every module. - Preview lessons link to the lesson player; "Preview this course" (
[data-open-preview]) opens#preview-dialog, a native<dialog>listing them. Escape and a click on the backdrop close it. - The review histogram (
[data-histogram]) is generated to match the course's rating. - Enrol (
[data-enrol]) saves the enrolment in the browser and shows a toast. It doesn't take payment. Once enrolled, the button becomes "Start the first lesson" or "Continue learning (40%)" and opens the next lesson. - The enrol card is sticky on large screens and marked
data-back-to-top-hide, so the back-to-top button never covers it. - Related courses (
[data-related]) are up to three courses with the same instructor or topic.
Learning paths#
On the Learning paths page, function pages.paths. Each item in PATHS becomes a section in
[data-paths] with the anchor #<path id>, its courses in order with their instructor, level and
hours, the totals (courses, hours, weeks) and the bundle price (the sum of the courses' prices). The
home page shows the same paths as cards ([data-home-paths]).
Instructor profiles#
The Instructors page lists every instructor ([data-instructors], function pages.instructors).
The Instructor profile page (/instructor/?id=<id>, function pages.instructor) fills
data-bind elements (name, role, bio, rating, students, total reviews across their courses, number
of courses, first name), the portrait, the tags, their courses and three other instructors.
Student dashboard#
On the My learning page, function pages.learn.
- Stats (lessons done, hours learned, certificates) are calculated from saved progress.
- The learning streak counts consecutive days with a completed lesson; the week strip
(
[data-week], Monday to Sunday) ticks the days of this week that belong to the streak, and each day has a screen reader label ("studied" or "no study yet"). - Continue learning (
[data-continue]) shows the first enrolled course that isn't finished, its progress bar and a "Resume lesson" link to the lesson where the visitor left off. - Each enrolled course shows a progress ring (
.ring-progress, driven by the CSS variable--p). - Finished courses earn a certificate; "View certificate" opens it in
#cert-dialog. - Recommended courses are the three best-rated courses the visitor isn't enrolled in.
- Reset demo progress (
[data-reset]) clears saved progress and notes and reloads the page.
Until a visitor does something themselves, the dashboard shows sample progress (the DEMO
object in main.js: part of Design Systems from Figma to Code and React from Scratch, all of SQL
Essentials and a 6-day streak) so it never looks empty, with a note explaining it
([data-demo-note]). Their first enrolment or completed lesson replaces the sample with their own
progress (makeOwn()). To start everyone empty, replace DEMO's enrolled with {} and its
streak count with 0.
Lesson player#
On the Lesson player page (/lesson/?course=<course id>&lesson=<lesson id>), function
pages.lesson.
- The sidebar (
[data-side-curriculum]) lists every module and lesson with completion ticks and lengths, and marks the current lesson witharia-current="page". On small screens it's hidden behind the "Course contents" button ([data-curriculum-toggle], witharia-expanded). - Mark as complete (
[data-complete]) toggles the lesson, updates the streak and moves "last lesson" to the next unfinished one. Its state is exposed witharia-pressed. Completing the last lesson shows "Course complete! Your certificate is waiting in My learning." - Lessons other than previews are locked (a message,
[data-locked], and a dimmed player) until the visitor enrols. Marking a lesson complete also enrols them. - Opening a lesson of an enrolled course remembers it as the place to continue.
- Tabs (Overview, Notes, Resources) follow the ARIA tabs pattern: the arrow keys, Home and End
move between them (
setupTabs()). - Notes (
#lesson-notes) save automatically as you type (after a short pause) and are kept per lesson;[data-notes-status]says "Saved in this browser". - Previous and next links go through the course; after the last lesson, "next" goes back to My learning.
- The video area is a placeholder: put your player (an
<iframe>or<video>) inside the element markeddata-playerin thelesson-playerCustom HTML block, replacing thedata-video-artartwork. The Resources tab's text is a placeholder too.
Pricing#
On the Pricing page, function pages.pricing.
- The billing switch (
[data-billing-toggle],role="switch", in thepricing-heroCustom HTML block) swaps every[data-price]between itsdata-monthlyanddata-annualvalues, and every[data-billing-note]between itsdata-monthly-noteanddata-annual-note. Yearly is the default. - The Plus and Teams plan cards are Custom HTML blocks so that their prices can switch: edit the
prices in the attributes (
<span data-price="" data-monthly="$19" data-annual="$15">). The Free card is ordinary blocks. - The team seats calculator (
team-seats): the slider (#seats, 2 to 200) and number input (#seats-number, 2 to 500) stay in step. Values outside 2 to 500 show an error and are corrected when the field loses focus. The per-seat price is $24 (yearly) or $29 (monthly), less 5% from 10 seats, 10% from 50 and 15% from 200; the panel shows the per-seat price, the discount, the monthly total and the yearly bill. - The comparison table (
compare-plans) is a plain table in a Custom HTML block that scrolls sideways on small screens: edit its rows by hand. Each tick or dash has a screen reader text.
The calculator's prices are separate from the plan cards: var base = annual ? 24 : 29; in
pages.pricing, with the discount tiers on the line below. Change both places together, and the
home page's plans-teaser text if it mentions prices.
Page addresses#
The script links to other pages (course cards, lesson links, the catalogue). The theme tells it
where they are: freeframe_academy_page_urls() in inc/setup.php looks up the pages with the slugs
courses, course, paths, instructors, instructor, pricing, learn and lesson and
passes their addresses as window.freeframeAcademySettings.pages, keyed by the HTML version's file
names ('course.html', 'lesson.html'...); PAGE('course.html') in main.js returns the address.
A page that doesn't exist falls back to /<slug>/. Keep these slugs: if you rename a page, the
script's links go to the old address.
Scroll reveals and counters#
Blocks with the class reveal fade in as they scroll into view; anything above a revealed block is
revealed too, so a fast jump never leaves gaps. They are only hidden once JavaScript runs (html.js,
added by freeframe_academy_theme_script()), so the content is there without it. Elements with
data-count (plus optional data-decimals and data-suffix, written as ffd- classes on blocks)
count up from zero when they appear. With reduced motion, everything is shown at once and the
numbers appear without counting.
Header and mobile menu#
The header (#site-header) sticks to the top and gets a border and shadow (is-scrolled) after
8 px of scrolling. Below 1024 px the menu button (#menu-button, a Custom HTML block) opens
#mobile-menu, swaps its icon, updates aria-expanded and its label, and closes with Escape
(returning focus to the button), when a link is clicked or when the window grows past 1024 px.
Toasts#
Enrolling and completing lessons show a short message at the bottom of the screen (toast(), a
role="status" element, so screen readers announce it). It disappears after about three seconds.
Back to top#
A button appears after scrolling about one screen down; it scrolls to the top (instantly with
reduced motion) and moves keyboard focus to the main content. It lifts above toasts and anything
marked data-back-to-top-avoid, and hides while it would cover a sticky element marked
data-back-to-top-hide (the course page's enrol card). It is created by assets/js/main.js; to
remove it, delete the "Back to top" part of that file and the .back-to-top styles in
src/theme.css, then run pnpm build.
Block styles#
In the block settings' Styles panel:
| Block | Styles |
|---|---|
| Button | Fill (default, green), Secondary (white with a thin outline: "Browse all courses", the Free plan), Sun (yellow: the plan teaser's main button), On green (translucent white, for buttons on the green panels). WordPress's own Outline style is replaced by Secondary |
For a bigger button, add the class is-large in Additional CSS class(es); add w-full to make
it fill its column.
Forms#
All forms are in Custom HTML blocks. They validate in the browser and none send anything until you connect them.
| Form | Where | Validation |
|---|---|---|
Newsletter ([data-newsletter]) |
every page's footer | email present and well-formed |
Course search ([data-search-form]) |
Home (home-hero), 404 |
an empty search goes to the full catalogue; otherwise ?q= opens the catalogue with that search |
Catalogue search ([data-search-catalogue]) |
Courses (courses-hero) |
filters in place, see Course catalogue |
Teach with us ([data-teach-form]) |
Instructors (teach, #teach) |
name, email, topic chosen, idea at least 20 characters |
Team quote ([data-team-form]) |
Pricing (team-seats, #teams) |
name, work email, company or school |
Errors appear under each field (fieldError() adds a <p class="field-error"> with the id
<field id>-error, and sets aria-invalid and aria-describedby), and focus moves to the first
problem. On success the newsletter form is replaced by a message, and the other two hide the form
and show their success message ([data-teach-success], [data-team-success]), which receives
focus. The messages are translated in freeframe_academy_script_strings().
Enrolment and progress are front-end only too: to support real accounts, replace the functions in
the "Progress (localStorage)" section of main.js with calls to your backend, or use a learning
management plugin.
Option 1: a form plugin#
The usual way on WordPress. Install a form plugin (WPForms, Gravity Forms, Fluent Forms, Contact
Form 7, Jetpack Forms...), build the form there, then delete the form's Custom HTML block (on the
Instructors page, or the team quote card on the Pricing page) and insert the plugin's block or
shortcode in its place. The plugin sends the messages and stores them. To make its fields look like
the theme's, add the theme's classes where the plugin allows custom classes: input on fields,
label on labels, btn btn-primary on the submit button. The newsletter form can be replaced the
same way in the footer pattern, or by your email service's signup embed.
Option 2: a form service#
Keep the theme's form and send it to a service such as Formspree. For the team quote form:
-
Create a form on the service and copy its endpoint URL.
-
The fields already have
nameattributes, soFormDataincludes them:name,emailandcompany(andname,email,topicandideain the "Teach with us" form). -
In
assets/js/main.js, inpages.pricing, replace the lines after the comment "Demo only: POST new FormData(form) to your CRM or form service here." so it posts the form and only shows the success message when the service accepts it:js// Demo only: POST new FormData(form) to your CRM or form service here. fetch('https://formspree.io/f/your-form-id', { method: 'POST', body: new FormData(form), headers: { Accept: 'application/json' } }) .then(function (res) { if (!res.ok) throw new Error('Send failed'); form.hidden = true; var done = $('[data-team-success]'); done.hidden = false; done.focus(); }) .catch(function () { fieldError(company, 'Sorry, that did not send. Please try again.'); });Change the success message's text in the Custom HTML block too if it says nothing was sent.
The teach form works the same way in pages.instructors (before form.hidden = true;), and the
newsletter in the [data-newsletter] handler (the comment "Demo only: connect this form to your
email provider to collect sign-ups.").
Make these changes in a child theme (copy assets/js/main.js there and load it instead, see
Child themes), or they will be overwritten when you update the theme.
Saved data (localStorage)#
| Key | Shape | Written |
|---|---|---|
academy-progress |
{"enrolled": {"<course id>": {"done": ["m1-l1", …], "last": "m1-l2"}}, "streak": {"count": 3, "last": "2026-10-07"}} |
on enrol, mark as complete, and opening a lesson of an enrolled course |
academy-notes |
{"<course id>/<lesson id>": "note text"} |
as you type in a lesson's notes |
academy-view |
"grid" or "list" |
catalogue view buttons |
theme |
"dark" or "light" |
the theme switch |
The keys are the same as in the HTML version. They are validated when read:
cleanProgress()keeps only known course ids and lesson ids, drops duplicates and non-strings, resets the streak when its count isn't a whole number from 0 to 3,650 and requires dates likeYYYY-MM-DD. If anything was dropped, the cleaned value is written back; unreadable JSON is removed and the sample progress is shown.loadNotes()keeps keys shaped like<course id>/m<n>-l<n>with string values, cut to 5,000 characters, and writes the cleaned object back if anything changed. An empty note is removed.academy-viewis list only if it is exactly"list";themeis dark only if it is exactly"dark"(with no value, the system setting decides).
All storage access is wrapped in try/catch, so private browsing just turns saving off. Nothing is
sent to WordPress. To reset, use Reset demo progress on the My learning page (progress and
notes), or delete the keys in your browser's developer tools (Application, Local Storage).
SEO and sharing#
Page titles come from WordPress: the page title, an em dash and the site name (for example
"Pricing — Northfield Academy"); the home page shows the site name and tagline (Settings >
General). On the Course, Instructor profile and Lesson player pages the script then sets the tab
title from the data and the site title ("React from Scratch — Northfield Academy"), with the
translatable formats %1$s — %2$s and %1$s · %2$s — %3$s (lesson, course, site) in
freeframe_academy_script_strings().
Search engines that don't run JavaScript see one generic Course page, Instructor profile page and
Lesson player page. For indexable pages per course, create a page per course with your own content,
or use a learning management plugin. The theme doesn't output meta descriptions or Open Graph tags;
use an SEO plugin such as Yoast SEO, Rank Math or The SEO Framework, and give it
assets/img/og-image.png (or your own 1200 × 630 image) as the default social image. On the Course
page the script replaces the content of <meta name="description"> (if there is one) with the
course summary. Schools can add Course structured data. WordPress
already generates a sitemap at /wp-sitemap.xml; the My learning and Lesson player pages are
personal and can be left out of it with your SEO plugin.
Accessibility#
Built in: a skip link to the main content (added by WordPress, styled by the theme), labelled
landmarks (header, the "Main" and "Mobile" navigation, main, footer), one h1 per page, visible
focus rings in both themes, the menu button with aria-expanded and aria-controls, a labelled
theme switch with aria-pressed, a filters button with aria-expanded, view buttons with
aria-pressed, a live region for the catalogue's result count, native <details> for the
curriculum and questions, native <dialog> for the preview and certificate dialogs (Escape closes),
ARIA tabs with arrow keys, Home and End in the lesson player, aria-current="page" on the current
lesson and the current page's links, a billing switch with role="switch" and aria-checked, a
seats slider with aria-valuetext, progress bars with role="progressbar", progress rings, star
ratings, histogram bars and covers with text alternatives (decorative SVG is aria-hidden), screen
reader labels for the week strip, completed and locked lessons and the comparison table's ticks,
toasts and form success messages announced as status, form errors linked with aria-describedby
and aria-invalid with focus on the first error, and reduced-motion support (no reveals or
count-ups, instant scrolling).
When you edit: keep one Heading 1 per page, label every field you add to a form
(<label for="...">), keep text contrast at WCAG AA in light and dark mode (check new colours in
both), give images alt text (or mark them decorative), and keep link text meaningful.
Building and deploying#
You only need this if you change the theme's files. The stylesheet assets/css/theme.css is
compiled with Tailwind CSS from src/theme.css and the classes used in patterns/, parts/,
templates/, inc/ and assets/js/ (including the classes in the HTML the script writes, such as
the course cards):
pnpm install
pnpm build # compile assets/css/theme.css and assets/css/editor.css
pnpm watch # recompile assets/css/theme.css on every change (run pnpm build before you commit)Run pnpm build after adding a utility class to a pattern, template or main.js, otherwise the
class has no style. Classes you type in the editor's Additional CSS class(es) field are only
styled if the stylesheet already contains them.
To deploy, zip the theme folder (named freeframe-academy, without node_modules/) and upload it in
Appearance > Themes, or copy the folder to wp-content/themes/ on your server.
Translations#
The theme is translation-ready with the text domain freeframe-academy. All text in patterns
(including the Custom HTML blocks), templates and PHP goes through WordPress's translation
functions. The script's strings (card labels, durations, the course page, dashboard and lesson
player texts, toasts, form errors, day names) and the course data's text (topic names, instructor
roles, bios and tags, course titles, summaries, outcomes, modules and lessons, LESSON_PATTERN,
path names and blurbs, the student name) are translated in PHP (freeframe_academy_script_strings()
in inc/setup.php) and passed to the script as window.freeframeAcademyL10n. Ids, levels and badges
stay English in the data, because the filters compare them, and are translated where they are shown.
Instructor names and the updated dates are not translated. Create a translation with Loco
Translate, or with WP-CLI and Poedit:
wp i18n make-pot wp-content/themes/freeframe-academy wp-content/themes/freeframe-academy/languages/freeframe-academy.potSave freeframe-academy-fr_FR.mo (for example) in wp-content/languages/themes/ or in the theme's
languages/ folder. Page content created by Academy setup is copied into your pages in the site's
language at that moment; translate it in the editor like any other content. Prices are written with
a dollar sign and numbers are formatted for en-US in main.js (fmt(), priceLabel(),
money() in pages.pricing).
Child themes#
Use a child theme for changes you want to keep when Academy is updated. Create
wp-content/themes/academy-child/style.css:
/*
Theme Name: Academy Child
Template: freeframe-academy
*/Templates, parts, patterns and theme.json in the child theme override the parent's files of the
same name. To change the courses, instructors, paths, prices or form handling, copy
assets/js/main.js to the child theme and point the theme's script at it. Changing the script's
address keeps its translated strings and page addresses (deregistering the script would drop them):
add_action( 'wp_enqueue_scripts', function () {
$script = wp_scripts()->query( 'freeframe-academy' );
if ( $script ) {
$script->src = get_stylesheet_directory_uri() . '/assets/js/main.js';
}
}, 30 );The script's translations still come from the parent's freeframe_academy_script_strings(): text
you add to the data in your copy is shown as you write it, so write it in your site's language. The
parent's stylesheet only contains
the classes the parent uses: if your child theme's patterns or script use new utility classes,
compile a stylesheet for the child (copy package.json and src/theme.css, point the @source
lines at the child's folders) and enqueue it.
Troubleshooting#
- The dashboard shows courses I never took. That's the sample progress. Enrol in a course or complete a lesson, or press Reset demo progress.
- A new course doesn't appear, or every page's cards disappear. Check that its
instructormatches anidinINSTRUCTORSand itstopicis a key ofTOPICS, and look for errors in the browser console (a missing comma inCOURSESstops the whole script). - A course page shows the first course instead. The
?id=in the link must match the courseidexactly. - Links from course cards lead to a missing page. The script finds the pages by their slugs
(
course,lesson,courses...). Keep the demo slugs, or recreate the pages with Academy setup. - Progress disappeared after I renamed modules. Lesson ids come from module and lesson positions; renaming or reordering them changes which lessons saved ids point to. Unknown ids are dropped on the next visit.
- The calculator or catalogue stopped working after I added a section. The script picks a page's features from the first data section it finds (see Content). Keep sections such as Featured courses off the Pricing, Courses and other data pages.
- Some apostrophes are curly. WordPress turns straight quotes and apostrophes in page text into
curly ones (
wptexturize), so a few differ from the HTML version. To keep them straight, addadd_filter( 'run_wptexturize', '__return_false' );to a child theme'sfunctions.php. - The pages look unstyled or new classes do nothing. The stylesheet only contains classes it
found when it was built. Run
pnpm buildafter editing patterns or the script, then reload. - "This block contains unexpected or invalid content". A pattern or page was edited by hand with markup that doesn't match the block. Click Attempt recovery, or re-insert the section from Patterns > Academy.
- The course page, dashboard or forms disappear after saving a page. Users without the
unfiltered_htmlcapability (on multisite, everyone but super admins) can't save forms, dialogs and SVG in Custom HTML blocks: WordPress strips them. Edit those pages as an administrator (super admin on multisite). - The header menu shows other pages, or an old menu. A Navigation block without a chosen menu shows the most recent one. Select it and choose "Main menu" (or yours) in the block settings, in both the desktop and the mobile Navigation block.
- Academy setup says the pages exist, but they are empty. It never overwrites existing pages. Delete (and empty the trash of) the pages with those slugs, or open each page and insert its page pattern from Patterns > Academy pages.
- The forms don't send anything. They are demo forms; see Forms.
- A page shows the title twice or no title. Pages use a template without a title, because the page patterns start with their own heading. For a plain text page (privacy policy, terms), choose the Page with title template in the page's settings.
License#
MIT, which is compatible with the GPL that WordPress requires (see LICENSE.txt in the theme
folder). Free for personal and commercial use. The generated artwork, illustrations, logo and icons
are included under the same license. Plus Jakarta Sans and Caveat are licensed under the SIL Open
Font License 1.1 (assets/fonts/plus-jakarta-sans/OFL.txt, assets/fonts/caveat/OFL.txt).
Northfield Academy, its instructors and its learners are fictional.