Overview#
Loftwing is a multi-page, waitlist-first launch site for a startup, built as a WordPress block theme.
Its fictional brand is an email marketing platform for independent newsletters and small online shops,
based in Spokane, Washington, and still in private beta, so every page leads to one goal: joining the
waitlist for the public launch. The look is dark glass panels over a slow dusk sky, with original
homing pigeon artwork. 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/loftwing.
Headings, copy, lists, cards, quotes, buttons and the FAQs are core WordPress blocks arranged in block
patterns, so you edit them in the block editor without touching code. The parts that are worked out in
the browser (the waitlist forms and spot panel, the countdown, the subject-line tester, the tour's tools,
the calculator and comparison table, the migration estimator, the roadmap board, the journal list and the
contact form) work as in the HTML version: they are Custom HTML blocks driven by assets/js/main.js,
which reads its content from assets/js/data.js. Colors and fonts are design tokens in theme.json:
change them in Appearance > Editor > Styles and the whole site follows. Every date is worked out in
America/Los_Angeles (Pacific time) from rules rather than fixed dates, so the launch never arrives in
the demo.
Quick start#
Requirements: WordPress 7.1 or newer and PHP 7.4 or newer. No plugins are needed.
- Download
loftwing.zipfrom freeframe.dev/templates/loftwing (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 Loftwing.
- Go to Appearance > Loftwing setup and click Create the demo pages. This creates the Home, Product tour, Waitlist, Pricing, Switch to Loftwing, Roadmap, Journal and About 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.
- 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 the template page opens it in WordPress Playground, in your browser. To work on the theme's files locally, see Building and deploying.
Pages#
| Page | Pattern | Purpose and features |
|---|---|---|
/ |
page-home |
Waitlist form, live countdown, campaign editor and inbox mockup, subject-line tester, beta proof, six tool cards, quotes, cohort timeline, closing form |
/product/ |
page-product |
Five tabs: Loft Editor (preview size), Flightpaths (viewer), Flocks (segment builder), Homing Check (warm-up planner), Logbook (chart) |
/waitlist/ |
page-waitlist |
Four-field form, invite banner, your spot panel, invite link with copy, tier progress, invites accepted, FAQ |
/pricing/ |
page-pricing |
Calculator, three plan cards, comparison table, FAQ |
/migrate/ |
page-migrate |
Estimator with a saved checklist, hands-on time and warm-up days |
/roadmap/ |
page-roadmap |
18 items in three columns, one vote per item, status filter, sorting |
/journal/ |
page-journal |
Nine dated entries, topic filter, expandable text |
/about/ |
page-about |
Story, stance, team avatars, office address, contact form with a reference number |
| 404 | 404 |
The lost pigeon and links back to the main pages (templates/404.html) |
A page pattern is a list of section patterns. Each section is also insertable on its own from Patterns > Loftwing, and whole pages from Patterns > Loftwing pages.
Project structure#
freeframe-loftwing/
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 scripts (with the script's strings and the waitlist URL),
editor styles, the `js` class script, the sky layer, theme color, favicon,
meta descriptions, page titles, 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 block style
inc/site-setup.php Appearance > Loftwing 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/data.js all content the features read (window.LOFTWING)
assets/js/main.js every interaction (the HTML version's script plus a WordPress adapter)
assets/icons/ the icons used by Icon blocks
assets/img/ favicon.svg (also the logo), the page scenes, social image
assets/fonts/ Red Hat Display, Sen and Overpass Mono, with their licenses
src/theme.css the Tailwind CSS source of assets/css/theme.css
src/icons.css generated by the port (empty when no icon is drawn with a CSS mask)
src/editor-css.mjs writes assets/css/editor.css after each build
languages/ freeframe-loftwing.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.txtCustomizing#
Brand and colors#
Design tokens live in theme.json (settings.color.palette) and are bridged into Tailwind in the @theme
block of src/theme.css (--color-night: var(--wp--preset--color--night)), which makes utilities such as
bg-night, text-apricot and border-wire. Change a color in Appearance > Editor > Styles > Colors and
every utility that uses it follows, with no rebuild.
| Token | Value | Use |
|---|---|---|
| Night | #0f0a1e |
Page color and text on bright buttons |
| Plum | #241238 |
Solid raised surfaces: the mobile menu, table group rows |
| Dusk | #3a1d52 |
Tracks and quiet fills |
| Mist | #f7f1fb |
Headings and body text |
| Haze | #d6cbe3 |
Secondary text |
| Fog | #b8abc9 |
Captions and meta |
| Wire | #8c7da6 |
Field borders and chart axes |
| Coral | #ff8f70 |
Warm accent for buttons, marks and charts |
| Apricot | #ffcb9e |
Warm accent for text on glass, selected states, focus ring |
| Iris | #b9b1ff |
Cool accent for text and marks |
| Iris deep | #6f61e8 |
Cool accent for fills |
| Leaf | #9be8c2 |
Success, always with a word |
| Rose | #ffb4bc |
Errors, always with a word |
| Honey | #ffe08a |
Caution, always with a word |
The dusk sky is the .sky rule in src/theme.css: one fixed layer of soft gradients that drifts slowly,
printed first in the body by freeframe_loftwing_sky() in inc/setup.php. It sits behind the page, so the
page color is on html and theme.json sets no background for the body: don't set a background color for the
whole site in Styles, or it hides the sky. A few inline SVGs and main.js chart strings use the hex values
directly, so search for them if you change the palette completely. If you brighten the sky, check the contrast
of text-fog and text-haze on .glass panels as they scroll over its brightest part.
Blur is used on purpose in only two places, the header bar and the hero mockup. Other panels use .glass, which
is translucent without backdrop-filter, so scrolling stays smooth.
Typography#
Red Hat Display sets the headings, Sen the body text and Overpass Mono the numbers and labels. The files are
bundled in assets/fonts/ (the same files Google Fonts serves, so no request leaves your site) and declared in
theme.json, one fontFace per weight the design uses. To swap a font, pick another in Styles > Typography,
or add its files to assets/fonts/ and change the family in theme.json.
Content#
Text in pages is block content: open the page in Pages and edit it. What the features read is in
assets/js/data.js, on window.LOFTWING:
| Constant | What it holds |
|---|---|
DEMO |
KEEP_DEMO_CURRENT, MIN_WEEKS, FIXED_LAUNCH, LAUNCH_HOUR, TIME_ZONE, COHORT_WEEKS, FOUNDING_DAYS_BEFORE |
WAITLIST |
BASE, PER_REFERRAL, TIERS, WAVES, LIST_SIZES |
TOOLS |
The kinds of tool people move from, shared by the waitlist and the estimator |
SUBJECT |
Every rule and penalty the subject-line tester uses, plus SPAM_WORDS and GRADES |
PRICING |
BANDS, PLANS, COMPARE, ANNUAL_DISCOUNT, FOUNDING_DISCOUNT, OVERAGE_PER_THOUSAND_CENTS, DEFAULTS |
MIGRATION |
STEPS, SOURCES, SOURCE_NOTES, WARMUP, DEFAULTS, MAX_SIZE |
ROADMAP |
STATUSES and ITEMS |
JOURNAL |
TOPICS and ENTRIES |
AUTOMATIONS, SEGMENTS, ANALYTICS |
The product tour's Flightpaths, Flocks and Logbook figures |
Edit it in a child theme (Child themes) so an update doesn't replace your content. To add a
roadmap item, add an object to ROADMAP.ITEMS. The id must be unique, since saved votes refer to it:
{
id: "inbox-preview",
status: "next", // shipped, beta or next
votes: 88,
area: "Editor",
title: "Inbox previews",
body: "See your issue in the common mail apps before you send it.",
},If you add a journal topic, add a filter button with data-journal-filter to the Custom HTML block above the list
on the Journal page as well.
Images and artwork#
All artwork is SVG, drawn for this template. Each page has its own scene (the flock on the Product tour, the banded
pigeon on the Waitlist page, the three lofts on Pricing, the basket and route on Switch to Loftwing, the weather vane on
the Roadmap, the logbook on the Journal, the loft at dusk on About and the lost pigeon on the 404 page). Scenes and the
small drawings (tool cards, tiers, team avatars, the hero mockup) are inline SVG in Custom HTML blocks, so they keep the
theme's fonts and gradients. To use a photo instead, replace a Custom HTML block with an Image block of about the same
aspect ratio, for example 2:1 for the product hero and 13:8 for the about scene. Journal topics use the TOPIC_ART map in main.js. The
social image source is assets/img/og-image.svg, with og-image.png (1200 by 630) beside it. To use the theme's
favicon as your site icon, or another one, set it in Settings > General > Site Icon; favicon.svg is used until
you do.
Navigation, header and footer#
The header and footer are block patterns (patterns/header.php, patterns/footer.php), shown by the template parts in
parts/. Edit them in Appearance > Editor > Patterns > Template Parts. The two Navigation blocks in the header
(desktop and phone) show the same menu, created by the setup: edit it in Appearance > Editor > Navigation. WordPress
marks the current page's link with aria-current="page" itself. The header's waitlist buttons have the class
ffd-spot-cta, which the script's adapter turns into data-spot-cta, and refreshSpotCallouts() in main.js changes
them to "Your spot: No. 18,413" once a visitor has joined. Footer links are List blocks; the copyright year is written by
PHP.
Dark mode#
The template is dark only, and color-scheme: dark is set in src/theme.css. There's no light theme or toggle.
Features#
Launch date and rolling dates#
Launch dates must never read as past, so they are worked out from today in main.js (rollingLaunchDay). While
DEMO.KEEP_DEMO_CURRENT is true, the launch is the second Tuesday of the first month whose second Tuesday is at
least MIN_WEEKS (16) weeks away, at LAUNCH_HOUR (9:00 AM) in America/Los_Angeles. On November 4, 2026 that
gives the written text, Tuesday, March 9, 2027.
| Date | Rule | On November 4, 2026 |
|---|---|---|
launch |
The rule above | Tuesday, March 9, 2027 |
foundingEnd |
FOUNDING_DAYS_BEFORE (1) day before launch |
Monday, March 8, 2027 |
cohort5 |
COHORT_WEEKS.cohort5 (12) weeks before launch |
Tuesday, December 15, 2026 |
cohort6 |
8 weeks before launch | Tuesday, January 12, 2027 |
cohort7 |
4 weeks before launch | Tuesday, February 9, 2027 |
Any element with data-date="launch" and data-format (long, medium, short, monthDay or year) is filled in
by initDates(). The text written in the patterns is the November 4, 2026 value. Elements with data-year get the current
year. Journal entries keep their real past dates.
For a real launch: set KEEP_DEMO_CURRENT: false and FIXED_LAUNCH: "YYYY-MM-DD" in data.js. Cohorts and
founding dates still follow from it, and after that moment the countdown changes to the [data-cd-live] message.
Update the dates written in your pages to match, so visitors without JavaScript see the right text.
Previewing: add ?now=YYYY-MM-DDTHH:MM to any page URL to see the site at that Pacific time, for example
/?now=2027-03-10T09:00. The clock keeps ticking from there. The parameter is validated, and anything else
is ignored.
Countdown#
[data-countdown] on the home page counts down to the launch instant in days, hours, minutes and seconds, in
initCountdown(). It has role="timer", with no live announcement of each tick. If the launch has passed, which can
only happen with a fixed date, [data-cd-cells] hides and [data-cd-live] shows.
Subject-line tester#
initSubjectTester() and scoreSubject() in main.js, with the rules in SUBJECT. Every score starts at 90 and is
clamped to 0 to 100:
| Rule | Points |
|---|---|
| 1 to 14 characters | -25 |
| 15 to 27 | -8 |
| 28 to 50 | +6 |
| 51 to 60 | -6 |
| 61 to 78 | -16 |
| 79 or more | -28 |
| Each spam word (whole phrase, any case) | -12, at most -36 |
| Each shouted word of 4+ capital letters | -8, at most -16 |
| The whole line in capitals (replaces shouted words) | -20 |
One ! |
-3 |
Two or more ! |
-10 |
$$ or ?? |
-8 |
Starts with Re:, Fw: or Fwd: |
-15 |
| Two or more emoji | -6 |
| Contains a digit | +2 |
Contains a merge tag such as {{first_name}} |
+2 |
Grades: 85 and up is "Ready to send", 65 is "Worth a tweak", 40 is "Needs work", and below that is "Likely to hit the
filters". Previews cut after MOBILE_LIMIT (38) and DESKTOP_LIMIT (60) characters, counted by code point, with an
ellipsis. The grade and flags are in an aria-live region.
Product tour#
initTabs() gives the five tabs full keyboard support (arrow keys, Home, End) and reads ?tab= (editor,
automations, segments, deliverability, analytics). Clicking a tab updates the URL. Each panel has its own small
tool:
- Preview size (
initDevicePreview):[data-device]buttons switch the email between full width and phone width. - Flightpaths (
initFlowViewer): radios show a flow fromAUTOMATIONS. - Flocks (
initSegmentBuilder): "all" multiplies the chosenSEGMENTS.RULESshares, and "any" uses1 - product(1 - share), on a list of 12,480. For example, "Opened" alone is 7,738, "Opened" and "Clicked" is 2,089, and either one is 9,018. - Warm-up planner (
initWarmupPlanner):MIGRATION.WARMUPgives the days, and each day's volume isround(size * (day / days)^2), reaching the full list on the last day. - Logbook (
initAnalyticsChart): radios redraw an inline SVG chart fromANALYTICS.METRICS.
Waitlist and referrals#
initWaitlist() in main.js. The form validates the address, publication name, list size and current tool, with
inline errors that clear as you correct them. Addresses are trimmed and lowercased.
- Position: a new spot starts at
WAITLIST.BASE + seq, whereseqcounts spots ever created on this device and never goes back, so a first spot is No. 18,413 and a spot number is never reused. - Duplicates: an address that's already on the list gets its existing spot, invites included. No other field is checked and no invite is credited.
- Invite link:
/waitlist/?ref=<code>. The code isLWplus six characters from an FNV-1a hash of the address, in the alphabetABCDEFGHJKLMNPQRSTUVWXYZ23456789.a@x.examplegivesLWSFASHK. - Arriving with
?ref=: a valid code is remembered until the visitor joins. Joining with a new address credits the spot with that code, if it's known, once only, and never to itself. A visitor who opens their own link sees their spot and a note. From there, "Use a different address" lets them join a second address from the link, which does count. - Matching an invite: the "Someone join without your link?" form in the spot panel adds an accepted invite by address. It refuses the visitor's own address and duplicates. This is what makes the tiers work without a server. On a real site, it would ask your backend whether that address joined.
Position is start - accepted * PER_REFERRAL - jumps of the tiers reached, and never below 1. WAVES then picks the
invite date:
| Invites | Tier | Jump | Starting at 18,413 | Invite with |
|---|---|---|---|---|
| 0 | None | 18,413 | Public launch day | |
| 1 | None | 18,388 | Public launch day | |
| 3 | Perch | 500 | 17,838 | Cohort 7 |
| 10 | Loft | 1,000 | 16,663 | Cohort 6 |
| 25 | Lead bird | 5,000 | 11,288 | Cohort 5 |
"Leave the waitlist" asks for confirmation first, then removes the spot from this device.
Accessibility: errors use aria-invalid and aria-describedby, focus moves to the first invalid field, then to the
spot heading after joining. The notice has role="status", and the progress bar is a labeled role="progressbar".
Pricing calculator#
initPricing() and planQuote() in main.js, reading PRICING.
- List price: the
BANDSrow for the list size, with inclusive tops, times the plan'sfactor(Solo 1, Shop 1.5, Press 2.2), rounded to a whole dollar. - Discounts are worked out in whole cents, once: monthly is
list * (100 - founding). A year islist * 12 * 85 * (100 - founding) / 100, rounded. Annual per month is that divided by 12, rounded. - Founding discount: 25%, plus the highest
extraDiscountof the tiers a saved spot has reached (Loft +5, Lead bird +10). - Overage: extra emails beyond
includedSendsper subscriber cost 40 cents per 1,000 or part of it. They aren't discounted, and they're added to the total. - Best fit: the first plan whose
includedSendscovers the sends, otherwise Press. - Above 100,000 subscribers: plan cards are replaced by a quote panel.
| Subscribers | Sends | Billing | Founding | Solo | Shop | Press |
|---|---|---|---|---|---|---|
| 5,000 | 4 | Monthly | Yes | $29.25 | $44.25 | $64.50 |
| 1,000 | 4 | Monthly | Yes | $9 | $19.50 | |
| 1,001 | 4 | Monthly | Yes | $18 | ||
| 1,000 | 4 | Annual | Yes | $7.65 ($91.80 a year) | ||
| 1,001 | 4 | Annual | Yes | $15.30 ($183.60 a year) | ||
| 1,001 | 4 | Annual | No | $20.40 | ||
| 1,001 | 4 | Monthly | No | $24 | ||
| 100,000 | 4 | Monthly | Yes | $276.75 | $609 | |
| 5,000 | 25 | Monthly | Yes | $74.50 (with $10 overage) |
An invalid number in the subscribers field shows an error and keeps the last good estimate. Moving the slider or pressing Reset clears the error. The slider covers 100 to 100,000, and the field accepts any whole number up to 1,000,000.
Migration estimator#
initMigration() in main.js, reading MIGRATION. Each kind of tool in SOURCES lists its steps from STEPS. A
step's minutes are minutes + scale.add * ceil(size / scale.per), and the "automations" checkbox removes the rebuild
step. WARMUP gives the warm-up days, with inclusive tops. When "Nothing yet" is selected, list size and automations
hide and the warm-up uses the smallest row. ?from= preselects a kind of tool.
| Kind of tool | Subscribers | Hands-on time | Warm-up |
|---|---|---|---|
hosted |
12,000 | 2 hr 35 min | 7 days |
hosted |
5,000 | 2 hr 20 min | 3 days |
hosted |
5,001 | 2 hr 25 min | 7 days |
store |
12,000 | 2 hr 55 min | 7 days |
selfhosted |
12,000 | 2 hr 35 min | 7 days |
spreadsheet |
12,000 | 2 hr 40 min | 7 days |
general |
12,000 | 2 hr 20 min | 7 days |
general, no automations |
12,000 | 1 hr 35 min | 7 days |
none |
Not used | 1 hr 10 min | 3 days |
Checked steps are saved, with the time left recalculated, and "Start over" clears them.
Roadmap voting#
initRoadmap() in main.js, reading ROADMAP. Each visitor has one vote per item, and pressing again removes it.
Votes are saved and added to the item's starting count. Status filters are aria-pressed buttons, and ?status=
(shipped, beta or next) opens one directly. Sorting is by most votes, fewest votes or name. Focus stays on the
vote button when the list redraws, and each vote is announced in a status region.
Journal#
initJournal() in main.js, reading JOURNAL. Entries are sorted newest first, with filter buttons and ?topic=.
Each entry's full text is in a <details> element.
FAQ#
The FAQs on the Waitlist and Pricing pages are Details blocks with the class faq, so they work with the keyboard
and without JavaScript. To add a question, duplicate one in the editor. Dates inside them are <time data-date>
elements: keep them when you edit the text in the code view, or the date stops rolling forward.
Menu and reveals#
initMenu() handles the mobile menu (aria-expanded, and Esc closes it and returns focus). initReveal() adds
.is-visible to .reveal elements with an IntersectionObserver, and also reveals everything above them. Both respect
prefers-reduced-motion.
Forms#
No form sends anything until you connect it. All of them validate in the browser and finish with a success state. They are
Custom HTML blocks, so their fields can be edited in place; keep the name attributes and data-* hooks.
| Form | Where | Validates |
|---|---|---|
Quick join ([data-quick-join]) |
Home | Email, then opens /waitlist/?email= |
Join ([data-join-form]) |
Waitlist | Email, publication (2+ characters), list size, current tool |
Match invite ([data-claim-form]) |
Waitlist | Email, not your own, not already matched |
Contact ([data-contact-form]) |
About | Name (2+), email, topic, message (20+). Ends with a reference like LW-7KQ2M |
Connect the waitlist#
In join() inside initWaitlist() in assets/js/main.js, post the fields and render the spot from the answer. The
position and code should come from your server. For the contact form, the same idea with Formspree goes in the submit
handler of initContact():
const response = await fetch("https://formspree.io/f/your-id", {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});
if (!response.ok) return; // show an error and keep the form
// then show the success stateShow the success state only when the response is OK. Once a backend runs the list, also remove the [data-claim-form]
shortcut or have it check with the server. On WordPress you can also replace a Custom HTML form with a form plugin's block.
Saved data (localStorage)#
Every read and write goes through the safeStorage object in main.js, which falls back to memory for the page view
when storage is blocked.
| Key | Shape | Validation on read |
|---|---|---|
loftwing:waitlist:v1 |
{ seq, current, spots: { email: { start, code, publication, size, tool, joined, referrals: [{ email, at, via }] } } } |
loadWaitlist() checks every field and drops anything odd |
loftwing:ref:v1 |
An invite code string | Must match /^LW[A-Z2-9]{6}$/ |
loftwing:votes:v1 |
An array of roadmap item ids | Only known ids are kept |
loftwing:migrate:v1 |
{ source, size, automations, done: [stepId] } |
Own-key checks, whole-number range |
loftwing:pricing:v1 |
{ subscribers, sends, billing, founding } |
Type and range checks |
To reset, delete the keys in your browser's developer tools, or run:
Object.keys(localStorage).filter((key) => key.startsWith("loftwing:")).forEach((key) => localStorage.removeItem(key));SEO and sharing#
Each page's browser title comes from its page title and the site title (document_title_parts in inc/setup.php); the home
page's is the site title and the tagline from Settings > General. freeframe_loftwing_page_description() prints the demo
pages' meta descriptions, by slug. Social sharing tags and a sitemap are left to WordPress (it has its own wp-sitemap.xml)
and to an SEO plugin if you use one; point its default image at assets/img/og-image.png.
Accessibility#
Built in: the skip link WordPress adds, landmarks, one <h1> per page, visible focus rings, labeled inputs with linked errors,
the WAI tab pattern, aria-pressed filters, status regions and prefers-reduced-motion support that stops the sky and the
reveals. Color is never the only signal: statuses have words. Text colors meet WCAG AA on glass panels at the brightest part of
the sky.
Groups can't store role, tabindex or aria-* attributes, so the tab panels carry them as classes (ffa-role--tabpanel,
ffa-aria-labelledby--tab-editor), and the script's adapter puts them back: keep those classes when you edit a panel. ffd-*
classes do the same for data-* attributes, and ff-hidden for hidden.
When you edit, keep one <h1> per page, label new inputs, and recheck contrast if you change the sky or .glass. Automated
tools often can't judge text over gradients, so check those by eye or with a pixel sampler.
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;
keep each class name whole in the script (not built from pieces). 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-loftwing, without node_modules/) and upload it in Appearance > Themes,
or copy the folder to wp-content/themes/ on your server. Use HTTPS, and connect the forms before you go live.
Translations#
The theme is translation-ready with the text domain freeframe-loftwing. All text in patterns, templates and PHP goes through
WordPress's translation functions; the script's fixed labels and messages (validation errors, statuses, button labels) are
translated in PHP (freeframe_loftwing_script_strings() in inc/setup.php). Sentences the script builds from pieces (dates,
counts, summaries) are written in English in main.js; translate them there. The content in assets/js/data.js is edited there
directly. Create a translation with Loco Translate, or with WP-CLI and Poedit:
wp i18n make-pot wp-content/themes/freeframe-loftwing wp-content/themes/freeframe-loftwing/languages/freeframe-loftwing.potSave freeframe-loftwing-es_ES.mo (for example) in wp-content/languages/themes/ or in the theme's languages/ folder. Page
content created by Loftwing 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 Loftwing is updated (your launch date, prices and roadmap in particular).
Create wp-content/themes/loftwing-child/style.css:
/*
Theme Name: Loftwing Child
Template: freeframe-loftwing
*/Templates, parts, patterns and theme.json in the child theme override the parent's files of the same name. To use your own
data.js, replace the parent's data script (freeframe-loftwing-data) under the same handle in the child's functions.php:
add_action( 'wp_enqueue_scripts', function () {
wp_deregister_script( 'freeframe-loftwing-data' );
wp_register_script( 'freeframe-loftwing-data', get_stylesheet_directory_uri() . '/data.js', array(), '1.0', array( 'in_footer' => true, 'strategy' => 'defer' ) );
}, 15 );Do the same for assets/js/main.js (handle freeframe-loftwing), keeping the inline window.freeframeLoftwing strings and page
URL. 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 launch date changed by itself. That's
KEEP_DEMO_CURRENT. Turn it off and setFIXED_LAUNCH. - The sky is gone. A background color for the whole site in Styles covers it. Clear it, and keep the page color in the Night token.
- My old test spot keeps appearing. It's saved in this browser. Press "Leave the waitlist", or clear the
loftwing:keys. - A referral didn't count. Credit needs a new address and a code that belongs to a spot known to this browser, and it isn't given to the spot's own address. A real backend removes the browser limit.
- Invite links or plan cards go to the wrong page. The script links to
/waitlist/. If you renamed that page, change its URL infreeframe_loftwing_page_urls()ininc/setup.php(in a child theme, filter the inline script). - 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 > Loftwing.
- Loftwing 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 > Loftwing pages.
License#
Split license: the PHP code and block markup are licensed under the GNU GPL, version 2 or later, and the CSS,
JavaScript, images and design under the Freeframe Commercial License (unlimited sites for you and your
clients; no resale or redistribution as a theme). The full terms, with your licensee details, are in
LICENSE.txt and at https://freeframe.dev/license#commercial. Third-party components keep their own licenses.
Fonts: Red Hat Display, Sen and Overpass Mono are licensed under the SIL Open Font License 1.1 (OFL.txt in
each folder of assets/fonts/). All business, product and personal details are fictional, and any resemblance
to real ones is a coincidence.