Overview#
Echo is a podcast website built as a WordPress block theme. The fictional show is Overtone, an
independent interview podcast about how things get made, hosted by a designer and a science
journalist in a former piano workshop in Philadelphia. The theme has a home page with the latest
episode and its own player, a guest ticker, four featured episodes, topics, the hosts, listener
reviews and a newsletter; an episode archive with search, season and topic filters, sort and load
more; an episode page with a waveform player, clickable chapters, show notes, a searchable
transcript, a guest card and timestamped share links; a hosts and story page; a subscribe page with
podcast apps, the RSS feed and an FAQ; an advertise page with audience charts, ad formats, a rate
card and an enquiry form; a be-a-guest page with a pitch form; and a 404 page. A mini player
follows visitors from page to page and remembers where they stopped. 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/echo.
The pages (headings, copy, hosts, reviews, press, the rate card text, buttons) are core WordPress
blocks arranged in block patterns, so you edit them in the block editor without touching code. The
episodes work as in the HTML version: the 62 episodes, their chapters, notes and transcripts, the
topics and the audience figures are lists in assets/js/main.js, and the script draws the episode
cards, the archive, the episode page, the players and the charts from them. There are no photos:
every cover, waveform, avatar and portrait is SVG. Colours and fonts are design tokens in
theme.json: change them in Appearance > Editor > Styles and the whole site follows. The design is
dark only.
Quick start#
Requirements: WordPress 7.1 or newer and PHP 7.4 or newer. No plugins are needed.
- Download
echo.zipfrom freeframe.dev/templates/echo (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 Echo.
- Go to Appearance > Echo setup and click Create the demo pages. This creates the Home, Episodes, Episode, Hosts & story, Subscribe, Advertise and Be a guest 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.
- In Settings > General, the demo uses "Overtone" as the Site Title: the header and footer show it in lower case next to the logo, and the big outlined word at the bottom of the footer is the same name. Set your own.
- Visit your site. 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 > Echo pages) made of section patterns. Every section is also offered on its own in the block inserter under Patterns > Echo.
| Page | Slug | Page pattern | Sections (pattern files in patterns/) |
|---|---|---|---|
| Home | / |
page-home.php |
home-hero (latest episode player), guest-ticker, start-here (episode cards), topics, home-hosts, reviews, newsletter |
| Episodes | /episodes/ |
page-episodes.php |
episodes-header (show stats), episodes-filters (search, season, sort), episodes-results (topics, results, load more) |
| Episode | /episode/?id=62 |
page-episode.php |
episode-hero (header and player), episode-details (about, chapters, show notes, transcript, guest, share, apps), episode-related, episode-missing (shown for an unknown id) |
| Hosts & story | /hosts/ |
page-hosts.php |
hosts-hero, hosts-cards, hosts-numbers, hosts-story (timeline), hosts-process, press, team-cta |
| Subscribe | /subscribe/ |
page-subscribe.php |
subscribe-hero (next release), platforms (apps and RSS feed), subscribe-faq, subscribe-letter |
| Advertise | /sponsor/ |
page-sponsor.php |
sponsor-hero, sponsor-stats, audience-charts, ad-formats, rate-card, sponsor-enquiry |
| Be a guest | /guest/ |
page-guest.php |
guest-hero, guest-criteria, guest-pitch (form) |
| 404 | any missing URL | templates/404.html |
404 |
The script finds the episode and episodes pages by their slugs (episode, episodes): if you
rename one, change its URL in freeframe_echo_page_urls() in inc/setup.php too. 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-echo/
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 (with its strings and page URLs), editor styles,
the `js` class script, the film grain body class, 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 the Ghost button style
inc/site-setup.php Appearance > Echo 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 episode data, artwork, player, archive, episode page, charts, forms
assets/icons/ SVG icons for the Icon block and the podcast app links
assets/img/ logo, host portraits, favicon, social image
assets/fonts/ Schibsted Grotesk, Syne and DM Mono, with their licenses
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/ freeframe-echo.pot, the template for 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 | Default | Used for |
|---|---|---|
| Base (Night) | #0e0c13 |
the page background |
| Ink 900, Ink 800, Ink 700 | #15121c, #1c1826, #272132 |
sections and the footer, cards and fields, raised panels and hover |
| Line | #342c42 |
borders, rules, the unplayed part of a waveform |
| Contrast (Paper) | #f4efe6 |
headings and body text |
| Muted, Subtle | #b6adc2, #948aa3 |
secondary text, captions and labels |
| Lilac, Lilac soft, Lilac deep | #b9a4ff, #d6caff, #8f74ff |
the brand: buttons, links, the played waveform |
| Tomato | #ff6b4a |
the "on air" dot, highlights, craft |
| Sun, Sky, Mint | #f6d45a, #86d2ff, #7fe0b6 |
stars and chapter marks, topic dots, charts |
| White | #ffffff |
the newsletter field |
The stylesheet reads these colours from the palette (bg-lilac, text-muted...), so changing Lilac
in Styles recolours every button and link at once. A few colours are written directly into SVG:
the season cover colours (SEASONS), the chart colours (AUDIENCE.work and initCharts()) in
assets/js/main.js, the logo (assets/img/logo.svg), the 404 flatline and the favicon.
Typography#
Three fonts are bundled in assets/fonts/ and declared in theme.json
(settings.typography.fontFamilies), so no request goes to Google: Schibsted Grotesk (variable,
400 to 800 with italics; preset schibsted-grotesk) for text, Syne (600 to 800; preset syne)
for the display headings (display, h-hero, h-page, h-section, font-display) and DM Mono
(400 and 500; preset dm-mono) for labels, times and the meta style. To use other fonts, install
them in Styles > Typography > Fonts (the Font Library) and select them, or replace the
fontFace entries in theme.json. The utility classes read the presets, so keep those slugs. The
generated cover art names Syne and DM Mono directly (font-family="Syne, sans-serif").
Font sizes are the presets in theme.json (xs 0.75rem to 9xl 8rem, the Tailwind scale).
Content#
Edit pages in Pages: click any text to change it, select a button to change its link. 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 > Echo.
What the script draws or fills is in Custom HTML blocks: edit those in the block's HTML view, and
keep their ids and data- attributes.
| Pattern | Custom HTML | What it is |
|---|---|---|
home-hero |
the rings and the latest episode panel (data-player-ui="latest") |
see The player |
guest-ticker |
the scrolling list of guest jobs | hidden from screen readers (decoration) |
start-here, episode-related |
<ul data-ep-cards="18,41,2,33">, #related-list |
episode cards from the data |
topics |
<ul data-topic-list> |
one row per topic with its episode count |
reviews |
the star ratings | text alternatives for the stars |
newsletter, subscribe-letter |
the decorative waveform, the forms and their success boxes | see Forms |
episodes-filters, episodes-results |
the search form, season buttons, sort, topic buttons, results and empty state | see Episode archive |
episode-hero, episode-details |
the glow, breadcrumb, cover, player, chapters, notes, transcript, avatar, links and share box | see Episode page |
episode-missing |
<template id="episode-missing"> |
the message for an unknown episode |
platforms |
the RSS feed card | its address comes from SHOW.feed |
audience-charts, rate-card |
the four charts and the rate table | see Charts |
sponsor-enquiry, guest-pitch |
the forms and their success boxes | see Forms |
header, footer |
the menu button, the outlined name, the mini player and the toast | driven by the script |
A few blocks carry a class (Block settings > Advanced > Additional CSS class(es)) that the script or stylesheet reads:
ffd-<name>--<value>is turned into the attributedata-<name>="<value>"when the page loads (blocks can't store data attributes):ffd-ep-titleon the episode page's heading marks it to be filled with the episode's title,ffd-episode-countshows the number of episodes,ffd-latest-linkpoints a button at the newest episode. On a Button block the attribute goes on its link.ff-episode-partmarks the episode page's sections; the script groups them so a bad?id=can replace them with the not-found message.revealfades a block in as it scrolls into view.link-cardon a group makes the whole group clickable (the 404 cards, the logo).eyebrow(small mono label with a tomato dot),meta(mono caption),lead,h-hero,h-page,h-section,card,panelandchipare the HTML template's text and surface styles.
The episode data
Everything about episodes is at the top of assets/js/main.js:
| Constant | What it holds |
|---|---|
SHOW |
The show name (the Site Title), the RSS feed address shown on the subscribe page, and the credits line added to show notes |
TOPICS |
Topic ids, labels, colour dot class and a one-line description |
SEASONS |
Season labels, years and the cover colours for each season |
EPISODES |
One object per episode |
DETAILS |
Optional chapters, show notes, transcript and guest bio and links for an episode id |
AUDIENCE |
Numbers for the advertise page charts |
An episode looks like this. duration is in seconds and date is YYYY-MM-DD:
{ id: 63, season: 4, title: "The Sound of Snow", guest: "Dr. Mira Holm", role: "Snow physicist", topic: 'science', date: '2026-10-21', duration: 3540,
summary: "Why fresh snow makes a city quiet, and how Mira measures it.",
quote: "Snow is a very large, very cold acoustic panel.",
src: 'https://example.com/audio/episode-63.mp3' }The newest date becomes the latest episode everywhere: the home hero, the "New episode" links and
the subscribe page's next release date (two weeks later). The home page's "Start with these four"
list is the data-ep-cards attribute in the start-here pattern's Custom HTML.
To give an episode real chapters, notes and a transcript, add an entry to DETAILS with its id.
Times are seconds from the start. Notes are blocks: a string is a paragraph, { h } a heading,
{ ul } a list and { links } a list of [label, url] pairs (links written episode.html?id=62
go to your Episode page):
63: {
bio: 'Mira Holm studies how snow absorbs sound at a research station in Tromsø.',
links: [['Mira’s lab', 'https://example.com/snow-lab']],
chapters: [{ t: 0, title: 'Cold open' }, { t: 140, title: 'Meet Mira' }, { t: 1260, title: 'Measuring quiet' }],
notes: ['Paragraph of show notes.', { h: 'Mentioned' }, { links: [['Episode 62: The Quietest Room on Earth', 'episode.html?id=62']] }],
transcript: [[0, 'Eli', 'From a former piano workshop in Philadelphia, this is Overtone.'], [140, 'Mira', 'Thanks for having me.']]
}Episodes without a DETAILS entry still get a full page: chaptersFor() spreads a standard running
order across the episode, notesFor() builds notes from the summary, chapters and related episodes,
and transcriptFor() writes a short excerpt marked as such. Replace these with your real content.
The episode page's blocks contain episode 62 as default text; the script replaces it with the
episode from the URL. Edit assets/js/main.js in a child theme (copy it there and load it instead),
or your episodes are overwritten when you update the theme. Episode titles, summaries and
transcripts are data, not translatable strings.
Images and artwork#
There are no photos. All artwork is SVG:
- Cover art is generated by
cover(ep)inassets/js/main.js: the season colour fromSEASONS, a motif for the topic and the episode number. Covers are square. To use your own artwork, return an<img src="…" alt="">string fromcover()instead (square, at least 600×600). - Waveforms come from
bars()andwaveSVG(), a stable shape per episode. They are decorative; they don't analyse audio. - Guest avatars (
avatar(ep)) are the guest's initials on the season colour. - Host portraits are
assets/img/host-nadia.svgandassets/img/host-eli.svg(5:6) in Image blocks: select one and Replace it with a photo of the same ratio, for example 800×960.
The logo is assets/img/logo.svg in an Image block next to the Site Title block in the
header and footer. The podcast app icons before the "Listen on" links are drawn with CSS masks
(icon-before-spotify...; artwork in src/icons.css), and the icons on the be-a-guest page are
Icon blocks: select one and pick another from the Echo collection or WordPress's own. The social
image is assets/img/og-image.png (1200 × 630, source og-image.svg); the favicon
assets/img/favicon.svg is 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 and the one in the
mobile menu. Edit the menu once in Appearance > Editor > Navigation (the setup creates "Main
menu": Home, Episodes, Hosts, Advertise, Be a guest, Subscribe). Its first link (Home) and
last link (Subscribe) are hidden in the desktop header, as in the HTML version, because the
logo and the Subscribe button do their job; keep that order, or remove the .site-nav … :first-child, … :last-child rule in src/theme.css. On an episode page the Episodes link is marked
as the current page. 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, for both blocks.
The footer's three link columns are List blocks: click a link to change it, press Enter to add one. The copyright year is printed by PHP. The mini player and the toast are Custom HTML blocks at the end of the footer pattern; keep them on every page.
Features#
The player#
Every element with data-player-ui is a player, wired up by bindPlayer():
| Attribute value | Where | Controls |
|---|---|---|
data-player-ui="latest" |
Home hero | Always the newest episode |
data-player-ui="62" |
#episode-player on the episode page (set from the URL) |
That episode |
data-player-ui="mini" |
#mini-player on every page (footer pattern) |
Whatever is loaded |
They share one engine, Player, so every player and play button stays in sync. Inside a player
these hooks are used when present: [data-act="toggle"], "back", "fwd", "rate", "mute" and
"close" buttons, [data-wave] (the waveform scrubber, an invisible range input over two SVG
layers), [data-vol], [data-cur], [data-dur], [data-bar] and the data-ui-* text hooks. Any
button with data-play="<id>" plays or pauses that episode. The skip length is SKIP (15 seconds)
and the speed cycle RATES (1, 1.25, 1.5, 1.75, 2, 0.75).
Real audio or a simulated clock. If an episode has a src, Player.load() creates an <audio>
element and the time, duration, speed and volume come from the real file. Episodes without src
play on a clock that advances every 250 ms, so the demo plays all 62 episodes with no audio files.
To go live, add src to each episode: upload the MP3s to Media (or use your podcast host's MP3
URLs) and paste the full URL. Keep duration accurate: it is shown before the file has loaded.
Keyboard. Inside any player: Space or K play or pause, ← / → or J / L skip 15 seconds, ↑ / ↓ change the volume and M mutes. On the waveform the arrows move 5 seconds, Page Up / Page Down a minute and Home / End jump to the ends. On the episode page, K, J, L and M also work anywhere while you are not typing in a field; the player lists them under "Shortcuts".
Accessibility. Every player is labelled; the play button says what will happen ("Play episode
62: The Quietest Room on Earth"); the seek slider announces its position in words; mute is a toggle
with aria-pressed. The equaliser bars and the ripples around the home cover only move while
playing and stay still with reduced motion.
Mini player and saved progress#
#mini-player slides up at the bottom of the page once an episode is loaded, and the page gets
extra bottom padding (.has-mini). On an episode page it tucks away while that episode's own player
is on screen. The close button stops playback and hides it; the position is kept. The position is
saved every five seconds while playing and on pause, seek and page change; the next page loads the
same episode at the same second and carries on if it was playing (a real file may wait for a click
because of the browser's autoplay rules). Episode cards and archive rows show "23 min left" or
"Played" with a progress bar.
Episode archive#
initArchive() on the Episodes page filters EPISODES with filterEpisodes(): a search across
title, guest, role, summary and topic (matches are highlighted), one season, one topic and a sort
(newest, oldest, longest, shortest in SORTS). The state is written to the URL, so filtered
views can be shared:
/episodes/?q=bells&season=2&topic=craft&sort=oldestUnknown values are ignored. Topic buttons show live counts within the current season and search.
Results show 12 at a time (PAGE_SIZE); "Show more" focuses the first new episode. Active filters
appear as removable chips, and an empty state ("Dead air.") offers to clear them. The home page's
topic rows link to /episodes/?topic=<id>.
Episode page#
initEpisodePage() fills the page from ?id= (no id shows the newest episode; an unknown id shows
the episode-missing message instead of the page).
- Chapters: each is a button that seeks there and plays. The current chapter gets
aria-current="true", is named above the waveform and is marked as a dot under it. - Transcript: each line is a button that seeks and plays. The current line is highlighted, and with "Follow along while playing" ticked the box scrolls to keep it in view (it pauses after you scroll it yourself). The search box filters lines and highlights matches.
- Share: the share box shows the episode link; tick "Start at" to add
&t=with the current second, then "Copy link"./episode/?id=41&t=754(ort=12:34) opens the episode at that point, paused, with a note saying so. Where the browser supports it, a "More" button opens the share sheet.
Charts#
initCharts() on the Advertise page draws the growth line chart and the occupation donut as SVG and
the age and country charts as CSS bars from AUDIENCE. Each SVG chart has a text alternative in
aria-label (in the audience-charts Custom HTML), and "Show the numbers" opens a data table.
Change the numbers in AUDIENCE and the aria-label text together. The rate card is an HTML table
in the rate-card pattern.
Subscribe page#
The RSS card shows SHOW.feed and its Copy button copies it. "Next episode" is two weeks after the
newest episode's date (moved forward until it is in the future). The FAQ answers are Details
blocks with the class faq; the plus that turns into a cross is drawn by CSS. Add one by
duplicating an answer in List View.
Header, menu, reveals and back to top#
The header turns solid after scrolling. Below 1024 px the menu button opens a full-height menu
(aria-expanded, Escape closes it). Blocks with reveal fade in on scroll; the guest ticker scrolls
with CSS and pauses on hover; both stop with reduced motion. The back-to-top button lifts itself
above the mini player and toasts; to remove it, delete the "Back to top" part of assets/js/main.js
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, the lilac button), Ghost (and Outline) |
The class is-large on a Button block is the HTML template's btn-lg; icon-after-arrow-right
adds the arrow; ff-dot adds the tomato "on air" dot.
Forms#
All forms with data-validate are checked by initForms() in assets/js/main.js: required fields,
email format, web addresses (type="url"), minimum lengths, required checkboxes and checkbox groups
(<fieldset data-required-group>). Errors appear under each field (in the element whose id is the
field id plus -error), the first invalid field gets focus, and fields re-check as you fix them. On
success the form is replaced by its success box (data-success="<id>"), which takes focus.
| Form | Where | Checks |
|---|---|---|
#newsletter-form |
Home (newsletter) |
|
#sub-form |
Subscribe (subscribe-letter) |
|
#sponsor-form |
Advertise (sponsor-enquiry) |
Name, company, email, optional website, at least one format, budget, message (30+ characters) |
#guest-form |
Be a guest (guest-pitch) |
Name, email, their name when "Someone else" is chosen (data-toggle-when), field, topic, optional link, story (120+ characters with a live counter), consent |
/sponsor/?format=season#enquire preselects a format (the ad format cards link there).
Nothing is sent, and the success messages say so. To collect submissions, use a form plugin
(replace the form's Custom HTML block with the plugin's block; add the classes field,
field-label and btn-primary to its fields, labels and button), or post the form in onValid()
in section 10 of assets/js/main.js (in a child theme), for example to Formspree:
function onValid(form) {
fetch('https://formspree.io/f/your-form-id', {
method: 'POST', body: new FormData(form), headers: { Accept: 'application/json' }
}).then(function (res) {
if (res.ok) showSuccess(form); else toast('Something went wrong. Please email us.');
});
}Use a different endpoint per form by checking form.id, then change the success text in the
patterns to say the message was received. Every field has a name; keep one on any field you add.
Saved data (localStorage)#
| Key | Shape | Notes |
|---|---|---|
overtone-player |
{ "id": 18, "rate": 1.25, "volume": 0.9, "muted": false, "playing": true } |
The loaded episode and player settings. Unknown ids and speeds not in RATES are ignored on read, and the volume is clamped to 0–1. Without id, nothing is loaded |
overtone-progress |
{ "18": 1720.4, "62": 312 } |
Seconds listened per episode id. Positions within 2 seconds of the end count as played and restart from the top |
Values are read with readJSON(), which falls back to defaults on missing or broken data, and
written with writeJSON(), which ignores storage errors. To reset, run
localStorage.removeItem('overtone-player'); localStorage.removeItem('overtone-progress') in the
browser console. To rename the keys, change STORE_PLAYER and STORE_PROGRESS.
SEO and sharing#
Page titles come from WordPress: the page title, an em dash and the site name (for example
"Episodes — Overtone"); the home page shows the site name and tagline (Settings > General). The
episode page sets its title from the episode ("The Quietest Room on Earth (S4 E62) — Overtone") and
the meta description, if an SEO plugin prints one, once the script runs. 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. WordPress already generates a sitemap at /wp-sitemap.xml. Episodes are drawn by JavaScript
from one page; if search visibility for each episode matters, give each episode a page or post of its
own and publish your RSS feed through your podcast host.
Accessibility#
Built in: a skip link to the main content (added by WordPress, styled by the theme), landmarks
(header, labelled navigation, main, footer, the labelled players), one h1 per page, labelled
sections, a breadcrumb with aria-current, visible focus rings, the menu button with
aria-expanded (Escape closes the menu), keyboard shortcuts for every player, play buttons whose
labels name the episode, a seek slider that speaks its position, filter buttons with
aria-pressed, live regions for the result count, the transcript count and the toast, FAQ answers
in <details>, text alternatives for the charts and the star ratings, form errors announced
through aria-describedby and aria-invalid, and reduced-motion support for every animation. The
demo passes axe (WCAG 2.1 A and AA).
When you edit: keep one Heading 1 per page, give images alt text (or mark them decorative), keep
link text meaningful, and label any field you add to a form (<label for="...">).
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/:
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 the script's markup,
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-echo, 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-echo. All text in patterns,
templates and PHP goes through WordPress's translation functions; the script's strings (player
labels, archive counts, chapter and note headings, share and form messages, topic and season names)
are translated in PHP (freeframe_echo_script_strings() in inc/setup.php). Episode titles,
summaries, chapters and transcripts are data in assets/js/main.js, and dates are formatted in
US English (date()). Create a translation with Loco Translate, or with WP-CLI and Poedit:
wp i18n make-pot wp-content/themes/freeframe-echo wp-content/themes/freeframe-echo/languages/freeframe-echo.potSave freeframe-echo-de_DE.mo (for example) in wp-content/languages/themes/ or in the theme's
languages/ folder. Page content created by Echo setup is copied into your pages in the site's
language at that moment; translate it in the editor like any other content.
Child themes#
Use a child theme for changes you want to keep when Echo is updated (your episodes in particular).
Create wp-content/themes/echo-child/style.css:
/*
Theme Name: Echo Child
Template: freeframe-echo
*/Templates, parts, patterns and theme.json in the child theme override the parent's files of the
same name. To use your own copy of assets/js/main.js, dequeue the parent's script
(freeframe-echo) and enqueue yours in the child's functions.php, keeping the inline
freeframeEchoL10n and freeframeEchoPages objects (freeframe_echo_script_strings(),
freeframe_echo_page_urls()). The parent's stylesheet only contains the classes the parent uses: if
your child theme uses 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 pages look unstyled or new classes do nothing. The stylesheet only contains classes it
found when it was built. Run
pnpm buildafter editing patterns, 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 > Echo.
- Episode cards link to a page that doesn't exist. The script links to
/episode/and/episodes/. If you renamed those pages, updatefreeframe_echo_page_urls()ininc/setup.php. - My audio file doesn't play. Check the
srcURL opens in the browser and is an MP3 or M4A the browser supports; a toast says when a file can't be loaded. The first play after a page change may wait for a click (autoplay rules). - The episode page shows "That episode isn't in the archive." The
?id=doesn't match anidinEPISODES. - Home or Subscribe is missing from the desktop menu. The desktop header hides the menu's first and last links. See Navigation, header and footer.
- Echo 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 > Echo pages.
- The forms don't send anything. They are demo forms; see Forms.
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 cover art, waveforms, avatars, portraits, logo
and social image are included under the same license. Schibsted Grotesk, Syne and DM Mono are
licensed under the SIL Open Font License 1.1 (OFL.txt in each folder of assets/fonts/).