Freeframe

Documentation

Academy

v1.0.0Updated Oct 8, 2026A friendly online course platform with a catalogue, lesson player and student dashboard.

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.

  1. Download academy.zip from freeframe.dev/templates/academy (the WordPress tab).
  2. 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.
  3. 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.
  4. 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:

sh
pnpm install
pnpm dev     # builds the CSS, then starts WordPress with the theme at http://127.0.0.1:9400

pnpm 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.txt

Customising#

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:

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:

  • reveal fades a block in as it scrolls into view (reveal-delay-1 to reveal-delay-3 stagger it).
  • link-card on a group makes the whole group clickable: the first link inside it stretches over the group (the logo, the topic tiles).
  • ff-mobile-only on a menu link hides it in the desktop menu (see Navigation, header and footer).
  • ff-hidden on 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:

  1. 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'] },

    topic must be a key of TOPICS and instructor an id in INSTRUCTORS. level is Beginner, Intermediate or Advanced (the catalogue's level filter compares these English values). badge is shown on the card's cover (Bestseller, New; Free is not shown, the price says it).

  2. Add its title, summary, outcomes, modules and lessons texts to freeframe_academy_script_strings() in inc/setup.php if the site needs them translated (without them the English text is shown). A new badge or level value needs an entry there too.

  3. Update the text that counts the catalogue: "Twelve courses across design, frontend, backend, data and product." in the courses-hero section and the starting count "12 courses" in the catalogue Custom HTML block (the script replaces it as soon as it runs), and the numbers in the home page's outcomes section 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 modules becomes a module with ids m1, m2, and so on.
  • If the course has a lessons array (one array of lesson titles per module), those titles are used. Otherwise every module gets five lessons named from LESSON_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 example m2-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.svg in 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, source og-image.svg). WordPress doesn't output Open Graph tags by itself; see SEO and sharing.
  • Favicon: assets/img/favicon.svg is used until you set a Site Icon in Settings > General.

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_parents filter (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 (short under 5 hours, medium 5 to 10, long over 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 in COURSES, 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, topic and level (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], with aria-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 with data-bind-href point 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 with aria-current="page". On small screens it's hidden behind the "Course contents" button ([data-curriculum-toggle], with aria-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 with aria-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 marked data-player in the lesson-player Custom HTML block, replacing the data-video-art artwork. 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 the pricing-hero Custom HTML block) swaps every [data-price] between its data-monthly and data-annual values, and every [data-billing-note] between its data-monthly-note and data-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:

  1. Create a form on the service and copy its endpoint URL.

  2. The fields already have name attributes, so FormData includes them: name, email and company (and name, email, topic and idea in the "Teach with us" form).

  3. In assets/js/main.js, in pages.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 like YYYY-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-view is list only if it is exactly "list"; theme is 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):

sh
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:

sh
wp i18n make-pot wp-content/themes/freeframe-academy wp-content/themes/freeframe-academy/languages/freeframe-academy.pot

Save 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:

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 instructor matches an id in INSTRUCTORS and its topic is a key of TOPICS, and look for errors in the browser console (a missing comma in COURSES stops the whole script).
  • A course page shows the first course instead. The ?id= in the link must match the course id exactly.
  • 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, add add_filter( 'run_wptexturize', '__return_false' ); to a child theme's functions.php.
  • The pages look unstyled or new classes do nothing. The stylesheet only contains classes it found when it was built. Run pnpm build after 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_html capability (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.

Search documentation

Search the guides and every template's documentation