Overview#
Loftwing is a multi-page, waitlist-first launch site for a startup. 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. This is the Nuxt version, built with Nuxt 4, Vue 3, TypeScript and Tailwind CSS v4 and generated as a fully static site. This guide covers version 1.0.0. The template page, with a link to the live demo, is at https://freeframe.dev/templates/loftwing.
Quick start#
Requirements: Node.js 24 and pnpm (corepack enable pnpm).
pnpm install
pnpm devOpen http://localhost:3000. Pages reload as you edit. Other scripts:
| Script | What it does |
|---|---|
pnpm generate |
Static site in .output/public/ |
pnpm preview |
Serve the last build locally |
pnpm build |
Node.js server build in .output/ (pages still prerendered) |
pnpm lint |
ESLint |
pnpm typecheck |
nuxt prepare and vue-tsc |
Pages#
| Route | File | Purpose | Notable sections and features |
|---|---|---|---|
/ |
app/pages/index.vue |
Home | Waitlist form, live countdown, campaign editor and inbox mockup, subject-line tester, beta proof, six tool cards, quotes, cohort timeline, closing form |
/product |
app/pages/product.vue |
Product tour | Five tabs: Loft Editor (preview size), Flightpaths (viewer), Flocks (segment builder), Homing Check (warm-up planner), Logbook (chart) |
/waitlist |
app/pages/waitlist.vue |
The join flow | Four-field form, invite banner, your spot panel, invite link with copy, tier progress, invites accepted, FAQ |
/pricing |
app/pages/pricing.vue |
Founding pricing | Calculator, three plan cards, comparison table, FAQ |
/migrate |
app/pages/migrate.vue |
Switch to Loftwing | Estimator with a saved checklist, hands-on time and warm-up days |
/roadmap |
app/pages/roadmap.vue |
Launch roadmap | 18 items in three columns, one vote per item, status filter, sorting |
/journal |
app/pages/journal.vue |
Build journal | Nine dated entries, topic filter, expandable text |
/about |
app/pages/about.vue |
Company and contact | Story, stance, team avatars, office address, contact form with a reference number |
| 404 | app/error.vue |
Page not found | The lost pigeon and links back to the main pages |
Project structure#
loftwing/
nuxt.config.ts base path, head defaults, SITE_URL, prerendered routes, Tailwind
app/app.vue <NuxtLayout> and <NuxtPage>, keyed so a link to the page you're on starts it afresh
app/router.options.ts writes query strings the way the HTML version does
app/layouts/default.vue skip link, the dusk sky, header, page and footer
app/pages/ one file per page (index.vue, product.vue, waitlist.vue...)
app/error.vue the 404 page (generated as 404.html)
app/components/ the header, footer and the interactive parts (SubjectTester, QuickJoin...)
app/components/DateText.vue a launch or cohort date worked out in the browser
app/composables/ useWaitlist (the shared waitlist), useClient (dates and the countdown tick),
usePageSeo, useCurrentPage
app/data/site.ts all content the features read (DEMO, WAITLIST, PRICING...)
app/data/nav.ts the header menu
app/data/journal-art.ts the small drawing for each journal topic
app/utils/ the logic: dates, waitlist, subject, pricing, migration, storage, URL helpers
app/plugins/ start-up in the browser, scroll reveals, same-page links, a stable build
app/assets/css/main.css Tailwind: fonts, brand tokens and a few components
app/assets/fonts/ the font files and their licenses
public/img/ favicon.svg, og-image.svg, og-image.png
package.json scripts and dependencies
README.md short introduction
DOCS.md this guide
CHANGELOG.md the template's own changelog
LICENSE.txt added to the downloadCustomizing#
Brand and colors#
Design tokens live in the @theme block of app/assets/css/main.css and become Tailwind utilities such as bg-night,
text-apricot and border-wire. pnpm dev picks up changes right away.
| Token | Value | Use |
|---|---|---|
--color-night |
#0f0a1e |
Page color and text on bright buttons |
--color-plum |
#241238 |
Solid raised surfaces: the mobile menu, table group rows |
--color-dusk |
#3a1d52 |
Tracks and quiet fills |
--color-mist |
#f7f1fb |
Headings and body text |
--color-haze |
#d6cbe3 |
Secondary text |
--color-fog |
#b8abc9 |
Captions and meta |
--color-wire |
#8c7da6 |
Field borders and chart axes |
--color-coral |
#ff8f70 |
Warm accent for buttons, marks and charts |
--color-apricot |
#ffcb9e |
Warm accent for text on glass, selected states, focus ring |
--color-iris |
#b9b1ff |
Cool accent for text and marks |
--color-iris-deep |
#6f61e8 |
Cool accent for fills |
--color-leaf |
#9be8c2 |
Success, always with a word |
--color-rose |
#ffb4bc |
Errors, always with a word |
--color-honey |
#ffe08a |
Caution, always with a word |
To change the warm accent, edit the two tokens:
--color-coral: #ff7a90;
--color-apricot: #ffc3cf;The dusk sky is the .sky component in app/assets/css/main.css: one fixed layer of soft gradients that drifts slowly. A
few inline SVGs and the Logbook chart in app/pages/product.vue 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 Latin .woff2 files
are in app/assets/fonts/ with their licenses, declared with @font-face at the top of app/assets/css/main.css under
their plain family names, which --font-display, --font-sans and --font-mono refer to. Each file is a variable
font, and each weight the design uses has its own @font-face rule, like a Google Fonts stylesheet, so a weight that
isn't declared resolves to the same neighbor as in the HTML version. To swap a font, put the new file in
app/assets/fonts/, change its @font-face rules and change the matching token.
Content#
Fixed page copy is in the page files under app/pages/. Everything the features read is in app/data/site.ts:
| 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 |
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 it to FILTERS in app/pages/journal.vue and give it a drawing in TOPIC_ART in
app/data/journal-art.ts as well.
Images and artwork#
All artwork is inline SVG, drawn for this template. Each page has its own scene (the flock on /product, the banded
pigeon on /waitlist, the three lofts on /pricing, the basket and route on /migrate, the weather vane on
/roadmap, the logbook on /journal, the loft at dusk on /about and the lost pigeon on the 404 page). To use a photo
instead, replace an SVG with an <img> of about the same aspect ratio, for example 2:1 for the product hero and 13:8
for the about scene, and put the file in public/img/ and write its path with the base path (:src="`${$config.app.baseURL}img/hills.jpg`") so it keeps working under a sub-folder. The
social image is public/img/og-image.png (1200 by 630), generated from public/img/og-image.svg. The favicon is
public/img/favicon.svg, linked in nuxt.config.ts.
Navigation, header and footer#
The header is app/components/SiteHeader.vue, and the NAV array in app/data/nav.ts lists its links for both the
desktop and mobile menus. The footer is app/components/SiteFooter.vue. To add a page, create
app/pages/<route>.vue with a <main id="main" class="flex-1"> root, call usePageSeo() with its title and
description, add the route to routes in nuxt.config.ts, and add the link to NAV and the footer.
aria-current="page" follows the address (useCurrentPage()). The header's waitlist button is
app/components/SpotCta.vue, which changes it to "Your spot: No. 18,413" once a visitor has joined.
Dark mode#
The template is dark only, and color-scheme: dark is set in app/assets/css/main.css and nuxt.config.ts. 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 app/utils/dates.ts (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 |
Write a date with app/components/DateText.vue, whose slot is the November 4, 2026 text the generated HTML shows
until the page loads:
<DateText date="launch" format="long" datetime="2027-03-09">Tuesday, March 9, 2027</DateText>format is long, medium, short, monthDay or year. The footer fills the copyright year the same way. Both
work out their value in the browser once the page has hydrated (useLaunchDates() in app/composables/useClient.ts),
so the year and the dates don't freeze at build time, and there is no hydration mismatch. Journal entries keep their
real past dates.
For a real launch: set KEEP_DEMO_CURRENT: false and FIXED_LAUNCH: "YYYY-MM-DD" in app/data/site.ts. Cohorts and
founding dates still follow from it, and after that moment the countdown changes to the [data-cd-live] message.
Update the text inside each DateText to match, so the generated HTML reads right before the page loads.
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#
app/components/LaunchCountdown.vue counts down to the launch instant in days, hours, minutes and seconds, ticking
once a second (useTick()). 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#
app/components/SubjectTester.vue and scoreSubject() in app/utils/subject.ts, 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#
app/pages/product.vue gives the five tabs full keyboard support (arrow keys, Home, End) and reads ?tab=
(editor, automations, segments, deliverability, analytics). Clicking a tab updates the address. Each panel
has its own small tool, all in the same page:
- Preview size:
[data-device]buttons switch the email between full width and phone width (device). - Flightpaths: radios show a flow from
AUTOMATIONS(flow,flowNodes). - Flocks: "all" multiplies the chosen
SEGMENTS.RULESshares, and "any" uses1 - product(1 - share), on a list of 12,480 (segment). For example, "Opened" alone is 7,738, "Opened" and "Clicked" is 2,089, and either one is 9,018. - Warm-up planner:
MIGRATION.WARMUPgives the days, and each day's volume isround(size * (day / days)^2), reaching the full list on the last day (warmPlan). - Logbook: radios redraw an inline SVG chart from
ANALYTICS.METRICS(chart).
Waitlist and referrals#
app/pages/waitlist.vue for the page, app/utils/waitlist.ts for the rules and app/composables/useWaitlist.ts
for the state the header button, the home page and the pricing calculator share. 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 (app/plugins/client.client.tsdoes this on every page). 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#
app/pages/pricing.vue and planQuote() in app/utils/pricing.ts, reading PRICING. The comparison table's rows
come from PRICING.COMPARE, and its head prices follow the calculator (comparePrice()).
- 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#
app/pages/migrate.vue and app/utils/migration.ts, 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#
app/pages/roadmap.vue, 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#
app/pages/journal.vue, reading JOURNAL, with the drawings from app/components/TopicArt.vue. Entries are sorted newest first, with filter buttons and ?topic=. Each
entry's full text is in a <details> element.
FAQ#
The FAQs on /waitlist and /pricing are plain <details> elements in the page files, so they work with the keyboard
and without JavaScript. Dates inside them use DateText.
Menu and reveals#
app/components/SiteHeader.vue handles the mobile menu (aria-expanded, and Esc closes it and returns focus).
app/plugins/reveal.client.ts adds .is-visible to .reveal elements with an IntersectionObserver, and also
reveals everything above them. Both respect prefers-reduced-motion. Reveals only hide content once the js class is
on <html> (a head script in nuxt.config.ts), so the page reads fine without JavaScript.
Links to the page you're on#
In the HTML version every link loads a page. Here links move between pages without a reload, so
app/plugins/same-page.client.ts makes a link to the page that's already showing (a menu link, the footer) start that
page afresh at the top, like a page load, by raising the counter that keys the page in app/app.vue.
Forms#
No form sends anything until you connect it. All of them validate in the browser and finish with a success state.
| Form | Where | Validates |
|---|---|---|
Quick join ([data-quick-join]) |
app/components/QuickJoin.vue |
Email, then opens /waitlist?email= |
Join ([data-join-form]) |
app/pages/waitlist.vue |
Email, publication (2+ characters), list size, current tool |
Match invite ([data-claim-form]) |
app/pages/waitlist.vue |
Email, not your own, not already matched |
Contact ([data-contact-form]) |
app/pages/about.vue |
Name (2+), email, topic, message (20+). Ends with a reference like LW-7KQ2M |
Connect the waitlist#
In join() in app/pages/waitlist.vue, 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 submit() in
app/pages/about.vue:
const response = await fetch("https://formspree.io/f/your-id", {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form.value!),
});
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.
With pnpm build (a Node.js server build, see Building and deploying) you can post to a
server route of your own instead, in server/api/contact.post.ts:
export default defineEventHandler(async (event) => {
const form = await readFormData(event);
// validate, send the email or store the message, then answer
return { ok: true, name: form.get("name") };
});Saved data (localStorage)#
Every read and write goes through the safeStorage object in app/utils/safeStorage.ts, which falls back to memory
when storage is blocked or full. Keys are in STORAGE_KEYS. Saved data is read only in the browser, after the page
has loaded, so the generated HTML always shows the defaults.
| 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 calls usePageSeo({ title, description, page }) from app/composables/usePageSeo.ts, which sets the title,
description, canonical URL and Open Graph tags (and data-page on <body>). Set SITE_URL to your domain when you
build, so the canonical URL and og:image are absolute:
SITE_URL=https://www.loftwing.example pnpm generateRegenerate public/img/og-image.png from the SVG if you change the name or colors. Add a sitemap and robots.txt in
public/ if your host doesn't create them.
Accessibility#
Built in: a skip link, 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.
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#
pnpm typecheck
pnpm lint
pnpm generate # static site in .output/public/
pnpm preview # serve it locallynuxt generate prerenders every route listed in routes in nuxt.config.ts as <page>.html (pricing.html, linked
as /pricing), plus 404.html from app/error.vue. Next to each page is a folder with its _payload.json, which Nuxt
loads when you move between pages. Upload .output/public/ to any static host (Netlify, Cloudflare Pages, GitHub
Pages, S3, Vercel). Hosts that don't map /pricing to pricing.html on their own need that rewrite (Vercel:
"cleanUrls": true); most serve 404.html for unknown URLs.
To serve the site from a sub-folder, set DEMO_BASE_PATH, which becomes Nuxt's app.baseURL:
DEMO_BASE_PATH=/loftwing pnpm generatepnpm build makes a Node.js server build instead (run it with node .output/server/index.mjs); the pages are still
prerendered, and you can add server routes. demo/ is the freeframe.dev demo build and can be deleted.
pnpm typecheck runs vue-tsc against the configs Nuxt writes to .nuxt/ (tsconfig.json references them).
typescript is aliased to @typescript/typescript6, the TypeScript 6 API that vue-tsc and typescript-eslint load.
Troubleshooting#
- The launch date changed by itself. That's
KEEP_DEMO_CURRENT. Turn it off and setFIXED_LAUNCH. - 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.
- A new class has no effect. Tailwind only sees whole class names, so write them out in full and don't build them
from a template expression such as
`bg-${color}`. - The spot isn't there after a reload in a preview. Storage is blocked in sandboxed frames, so state lasts only for the visit. This is expected.
- Images or links break under a sub-folder. Build with
DEMO_BASE_PATH=/your-folder, use<NuxtLink>for pages and write files inpublic/with$config.app.baseURLin front. - A "hydration mismatch" warning after an edit. Anything that depends on the clock, the address or saved data must
be read in
onMounted()or afteruseClientReady()turns true, never while the page renders. - The sky looks banded or text feels dim after a color change. Keep the
.skygradients dark, and checktext-fogon.glassat the top right of the screen, where the sky is brightest.
License#
Freeframe Commercial License: use it in unlimited websites and apps for yourself and your clients; it may not
be resold or redistributed as a template. 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. Red Hat Display,
Sen and Overpass Mono are licensed under the SIL Open Font License. All business, product and personal details
are fictional, and any resemblance to real ones is a coincidence.