Overview#
Sprocket is a multi-page product launch and preorder website template for a hardware startup. The demo business is Bonfield Bikes, a fictional e-bike maker in Duluth, Minnesota, taking reservations for the Portage, a compact cargo e-bike built for hills and winter. This is the Nuxt version: Nuxt 4, Vue 3, TypeScript and Tailwind CSS v4, generated as a fully static site. This guide covers version 1.0.0 of the Nuxt stack. See the template page at freeframe.dev/templates/sprocket, which links to the live demo.
The template has a product configurator with an SVG bike that recolors and shows each accessory as you choose,
shareable build links and a saved build, a range estimator, production batches whose ship month and spots left
roll forward on their own, a reservation flow with a refundable deposit and a saved reservation, a car vs e-bike
cost calculator, a build log with articles, a team and careers page, and a support page with FAQ search, delivery
zones and a contact form. Every date is worked out in America/Chicago (Central time), whatever the visitor's own
time zone is.
Quick start#
Requirements: Node.js 24 or newer and pnpm (npm works too).
pnpm install
pnpm dev # http://localhost:3000With npm: npm install, then npm run dev.
| Command | What it does |
|---|---|
pnpm dev |
Development server at http://localhost:3000 with hot reload |
pnpm generate |
Static site in .output/public/ |
pnpm preview |
Serve the last build locally |
pnpm build |
A Node.js server build in .output/ (pages are still prerendered) |
pnpm typecheck |
nuxt prepare, then vue-tsc with Nuxt's strict settings |
pnpm lint |
ESLint 10 with @nuxt/eslint |
pnpm build:demo |
Build for /demos/nuxt/sprocket into demo/ (only used for the demo on freeframe.dev) |
Pages#
| Route | File | Purpose | Notable sections and features |
|---|---|---|---|
/ |
app/pages/index.vue |
Home | Hero with the Portage drawing and color swatches (shows your saved build), "Reserve yours" and "Build yours" links carrying the build, the open batch with spots left and a meter, owner numbers, feature highlights, press quotes, FAQ teaser, call to action |
/bike |
app/pages/bike.vue |
Configurator | Frame color, battery and accessories rendered from BUILD, live drawing, price breakdown, "Reserve this build", "Save this build", "Copy link", saved build panel; what every Portage includes |
/range |
app/pages/range.vue |
Range estimator | Presets, battery, rider and cargo weight, terrain, temperature, assist level, studded tires and round trip; result with a band, bars for both batteries, the working and a battery suggestion |
/reserve |
app/pages/reserve.vue |
Reserve | Build summary from the URL or saved build, delivery zone from the ZIP code, details form with validation, success state with a reference, saved reservation with a two-step cancel |
/specs |
app/pages/specs.vue |
Specs | Key figures, full specifications, footprint drawing, comparison table, car vs Portage cost calculator |
/journal |
app/pages/journal.vue |
Build log | Category filter (?cat=), the latest post featured, the rest in a grid, each with a drawn cover |
/article |
app/pages/article.vue |
One post (?a=<slug>) |
Title, category, date, cover, body, author with portrait, batch call to action, three more posts; unknown slugs show the latest post with a notice |
/about |
app/pages/about.vue |
About | Story with a hill map, timeline, seven illustrated team portraits, backers, open roles with a team filter (?team=, #careers) |
/support |
app/pages/support.vue |
Support | FAQ with search, warranty, delivery zones with a ZIP checker, contact form with success state (?topic=) |
| any other | app/error.vue (generated as 404.html) |
Not found | A wheel rolling down the hill and links to the main pages |
Project structure#
sprocket/
app/
app.vue <NuxtLayout><NuxtPage /></NuxtLayout>, with a page key (see "Links to the same page")
layouts/default.vue skip link, header, footer and the back-to-top button
pages/ one .vue file per page (see Pages)
error.vue the 404 page
components/
SiteHeader.vue batch bar, logo, main menu and mobile menu
SiteFooter.vue footer links, address, hours and copyright year
BikeArt.vue the Portage drawing, colored and fitted from a build
ColorSwatches.vue the frame color radio buttons (home hero and configurator)
PriceLines.vue price breakdown rows (configurator and reserve)
PostCard.vue a post card in the journal and under articles
BackToTop.vue back-to-top button
composables/
useClock.ts today in Central time (with ?now=): useToday(), useBatch()
useCurrentPage.ts aria-current for menu links
usePageSeo.ts per-page title, description, canonical and Open Graph tags
data/
site.ts all content and settings: COMPANY, BUILD, BATCHES, ZONES, RANGE, COST, DEMO, POSTS, PEOPLE, ROLES
nav.ts NAV: the header and mobile menu items
utils/
sprocket.ts dates in Central time, batches, builds and prices, range, zones, references, journal dates
art.ts article covers (coverSvg) and team portraits (portraitSvg) as SVG strings
store.ts saved build and reservation, validated on read
safeStorage.ts localStorage with an in-memory fallback, readJSON() and writeJSON()
url.ts replaceQuery(): keeps the build or a filter in the address bar
plugins/
clock.client.ts starts the clock after hydration and after each navigation
reveal.client.ts scroll reveals
same-page.client.ts links to the page already showing start it afresh
stable-build.server.ts reproducible builds (drops the build timestamp)
router.options.ts query strings written like the HTML version (?acc=rack,kidseat)
assets/css/main.css Tailwind CSS v4: @font-face, @theme tokens, utilities and components
assets/fonts/ Kanit and Encode Sans woff2 files with their OFL licenses
public/img/ og-image.svg and og-image.png (1200×630), favicon.svg
nuxt.config.ts base path, head defaults, SITE_URL, prerendered routes, Tailwind
package.json scripts and dependencies
README.md, DOCS.md, CHANGELOG.mdCustomizing#
Brand and colors#
The design tokens are in the @theme block of app/assets/css/main.css. Tailwind turns each --color-* token
into utilities (bg-ore, text-lake, border-ink…).
| Token | Value | Used for |
|---|---|---|
--color-snow, --color-snow-2 |
#f3f2ed, #e8e6de |
page background, sunken bands |
--color-card |
#ffffff |
cards and forms |
--color-ink, --color-ink-2, --color-ink-3 |
#14171a, #1e2327, #30373c |
text, dark bands, borders on dark |
--color-muted, --color-frost |
#575c5f, #c9d3d6 |
secondary text on light and on dark |
--color-ore, --color-ore-deep, --color-ore-soft |
#c63d1f, #a8301a, #f7dbd2 |
Taconite red: buttons, links, highlights |
--color-ember |
#ff8a66 |
the red on dark backgrounds |
--color-lake, --color-lake-deep, --color-lake-soft, --color-ice |
#0f6672, #0b4e57, #d4e8ea, #9fd9e3 |
Harbor teal accents |
--color-ok, --color-bad |
#2d6a3e, #a8301a |
success and error text |
To change the brand red, edit --color-ore and its partners (the dev server picks the change up at once), and
update the few hex values written into SVG art (the logo in SiteHeader.vue and SiteFooter.vue,
public/img/favicon.svg, the hero backdrop in app/pages/index.vue, the covers in app/utils/art.ts):
@theme {
--color-ore: #1d5fbf;
--color-ore-deep: #164a96;
--color-ore-soft: #d8e5f7;
}The bike's frame color is not a token: it comes from the chosen color in BUILD.colors (see
Configurator). The repeated pieces (.btn-ore, .card, .chip, .option, .swatch, .tread
dividers and so on) are in @layer components in the same file.
Typography#
Two fonts, self-hosted from app/assets/fonts/ with @font-face rules at the top of main.css (the same Latin
files Google Fonts serves, so lines wrap exactly like the HTML version):
- Kanit (500, 600, 700, 800 and italic 700, 800) for headlines (heavy italic uppercase through the
headlineutility), buttons, labels and numbers:font-display. - Encode Sans (variable, weights 400–700) for body text:
font-sans.
Figures use tabular, lining numerals through the num utility. To swap a font, put its woff2 files in
app/assets/fonts/, change the @font-face rules and the family names in --font-display and --font-sans in
@theme. The SVG art in app/utils/art.ts and public/img/og-image.svg names Kanit too.
Content#
Page copy lives in the page files in app/pages/. Everything that is listed, priced or calculated lives in
app/data/site.ts:
| Constant | What it holds |
|---|---|
COMPANY |
name, phone, email, address, timeZone (America/Chicago), reference prefixes (BB, BS) |
BUILD |
basePrice (3895), deposit (150), defaults, colors, batteries, accessories |
BATCHES |
firstShip: "2026-03", every: 2, lead: 3, size: 120, minLeft: 9 |
ZONES |
delivery zones by ZIP prefix, with fees |
RANGE |
the range formula, defaults, assist levels, terrain and presets |
COST |
the cost calculator's electricity, upkeep and years |
DEMO |
KEEP_DEMO_CURRENT and DEMO_ANCHOR for journal dates |
CATEGORIES, POSTS |
the build log |
PEOPLE |
the team, with portrait values |
TEAMS, ROLES |
open roles |
Adding an accessory is one entry; it appears in the configurator, the price, the reserve summary and saved builds.
The key is what goes into the URL (?acc=lock):
lock: { name: "Frame lock & chain", price: 95, detail: "A keyed-alike chain that plugs into the frame lock." },To draw it on the bike, add a <g data-part="lock" :hidden="shown('lock')">…</g> group to
app/components/BikeArt.vue; it shows only when the build has that accessory.
Adding a post: add an object at the top of POSTS (newest first) with a unique slug, a cat from CATEGORIES,
an author from PEOPLE, a date (YYYY-MM-DD), minutes, an art key from ART in app/utils/art.ts and a
body made of ["p", text], ["h2", text], ["ul", [items]] and ["quote", text, who] blocks.
The FAQ on /support is written out in app/pages/support.vue as <details class="faq-item"> elements; add or
remove one and the search and the count follow.
Images and artwork#
All artwork is SVG drawn for this template, no photos:
- The Portage is
app/components/BikeArt.vue(viewBox 800×500), used on the home page, the configurator and the reserve page. The frame strokes have the classframeand take their color from the--framecustom property; each optional part is a group withdata-part(rack,kidseat,panniers,basket,studs,pogies,battery-dual). To use photos instead, render one image per color inBikeArt.vueand keep a 16:10 frame. - Article covers are the
ARTdrawings inapp/utils/art.ts(640×400);coverSvg(post.art)picks one. Replace them with images of the same ratio if you like. - Team portraits are drawn by
portraitSvg(person)from theskin,hair,style(short,long,bun,bald,curly),shirt,glassesandbeardvalues inPEOPLE(square). - The hill map (about), zone rings (support), footprint chart (specs), feature art (home) and the 404 drawing are inline in their pages.
- The social image is
public/img/og-image.svg, exported toog-image.png(1200×630). The favicon ispublic/img/favicon.svg.
Covers and portraits are rendered with v-html. The strings come only from app/utils/art.ts and
app/data/site.ts, never from the URL or storage; keep it that way if you edit them.
Navigation, header and footer#
The header is app/components/SiteHeader.vue: the NAV array in app/data/nav.ts feeds both the main menu and the
mobile menu, and useCurrentPage() marks the current page with aria-current="page" (/article counts as the
journal). 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 and a usePageSeo() call, add the route to routes in nuxt.config.ts,
then add it to NAV and the footer.
The thin bar above the header (data-batch-line) shows the open batch on every page; delete the batch-bar block
in SiteHeader.vue to remove it.
Features#
Dates and the static build#
The generated pages can't know today's date, the URL or saved data, so anything that depends on them is worked out in the browser right after the page loads. Until then a page shows the text written for the static build (the batch of October 31, 2026, the standard Portage, the latest post), so search engines and people without JavaScript see a complete page, and Vue hydrates it without a mismatch.
app/composables/useClock.ts holds "now" in Duluth. app/plugins/clock.client.ts sets it once the first page has
hydrated and again after every client-side navigation; useToday() returns today's date key and useBatch() the
open batch, both reactive. Pages read the URL and storage in onMounted().
Production batches#
batchFor(dateKey) in app/utils/sprocket.ts works out the batch open on a date from BATCHES:
- Batch 1 ships in
firstShip(March 2026) and a batch ships everyevery(2) months. - The open batch is the first one shipping at least
lead(3) months after the current month, so each batch takes reservations for two calendar months and closes on the last day of the second. - Spots left fall in a straight line from
size(120) on the first day of that window to aboutminLeft(9) on the last:max(minLeft, round(size − (size − minLeft) × elapsedDays ÷ windowDays)).
On October 31, 2026 (Central time) that gives Batch 6, ships January 2027, 11 of 120 spots left (109 reserved), closes Saturday, October 31; Batch 7 opens November 1 and ships March 2027. On November 1, 2026 it is Batch 7 with 120 left. A year later (October 31, 2027) it is Batch 12, shipping January 2028, 11 left.
The header's batch bar, the home page panel, the reserve page and the article sidebar read useBatch(); the
elements keep the HTML version's data-batch-* attributes (data-batch-name, data-batch-ship,
data-batch-left…). For a real launch with fixed dates, replace batchFor() with your own table of batches.
Configurator#
/bike, in app/pages/bike.vue: the options are rendered from BUILD into [data-colors] (ColorSwatches.vue,
an ARIA radio group of swatches: arrow keys move and select), [data-batteries] (radios) and [data-accessories]
(checkboxes).
- Price:
basePrice$3,895 + battery (standard included, dual +$520) + accessories (rear rack $180, kid seat $240, panniers $160, front basket $135, studded tires $170, pogies $65). Example: Harbor Teal, dual battery and kid seat = $3,895 + $520 + $180 (rack, added automatically) + $240 = $4,835. - Dependencies: accessories with
requires: ["rack"](kid seat, panniers) add the rear rack, with the note "Rear rack & running boards added: the kid seat mounts on it."; removing the rack removes them ("Kid seat removed too: it mounts on the rear rack."). - Drawing:
<BikeArt :build="build" />sets--frame, shows the matchingdata-partgroups (the dual battery showsbattery-dual) and describes the build in the SVG'saria-label. - URL: every change updates the address with
history.replaceState(replaceQuery()):/bike?color=harbor&battery=dual&acc=rack,kidseat(a?now=preview is kept). "Reserve this build" links to/reservewith the same parameters. "Copy link" copies the address, or shows it in a field to copy when the clipboard isn't available. - Saved build: "Save this build" stores it under
sprocket:build; the panel below then shows "Your saved build: Harbor Teal · Dual battery · 2 accessories · $4,835" with "Load it" and "Forget it". On a later visit without parameters the saved build is loaded. The home page hero shows the saved build too.
Values from the URL and storage go through normalizeBuild(): unknown colors and batteries fall back to the
defaults, unknown accessories are dropped, and every key is checked with Object.prototype.hasOwnProperty.
Home hero#
In app/pages/index.vue, the swatches under the hero bike ([data-hero-swatches]) recolor it and update the
"Reserve yours" and "Build yours" links (data-hero-reserve, data-hero-build) so the chosen color carries over.
Range estimator#
/range, in app/pages/range.vue, with the formula in RANGE and rangeMiles():
miles = battery Wh × 0.9 × temperature factor ÷ (assist Wh/mi × terrain factor × weight factor × studs)
temperature factor = 1 at 60°F and above, else max(0.55, 1 − (60 − °F) × 0.0065)
weight factor = 1 + (rider and cargo lb − 200) ÷ 500
assist Wh/mi = Eco 9, Tour 13, Sport 18, Turbo 24
terrain factor = Mostly flat 1, Rolling 1.18, Steep hills 1.42; studded tires × 1.08The result shows the miles for the chosen battery (rounded), a band of ±12%, bars for both batteries with a
dashed line at the round trip × 1.25, the working, and a suggestion: the standard battery if it covers the round
trip × 1.25, else the dual battery, else "Dual battery, and a charge at work". The suggestion links to
/bike?battery=<key>. ?battery=dual on /range preselects a battery.
| Inputs | Standard | Band | Dual | Suggestion |
|---|---|---|---|---|
| Defaults: 220 lb, Rolling, 50°F, Tour, no studs, 12 mi | 33 mi | 29–37 | 47 mi | standard (36% of a charge) |
| January commute: 230 lb, hills, 5°F, Sport, studs, 14 mi | 12 mi | 11–14 | 18 mi | dual (113% / 79%) |
| School run: 290 lb, Rolling, 45°F, Tour, 6 mi | 28 mi | 25–31 | 40 mi | standard |
| Summer grocery run: 260 lb, flat, 75°F, Eco, 8 mi | 56 mi | 49–62 | 80 mi | standard |
The round trip must be 1–80 miles; otherwise an inline error shows and the last valid value is kept. The sliders
and the trip field keep what the visitor typed; syncForm() writes them only when a preset is picked.
Reservation#
/reserve, in app/pages/reserve.vue:
- Build: from the URL parameters, else the saved build, else the default; the line under the drawing says which ("Your build from the configurator.", "Your saved build.", "The standard Portage. Build your own to change it."). "Change" links back to the configurator with the build.
- Delivery zone: typing a ZIP code looks up
ZONESby its first three digits (zoneFor()):556–558and548are Duluth–Superior hand delivery (free), most of Minnesota, Wisconsin, Iowa, the Dakotas and the Upper Peninsula are Upper Midwest freight ($149), the rest of010–994is crated freight ($249), and Alaska, Hawaii and the territories (995–999,967–968,000–009) can't be reserved yet. The fee is added to the summary. - Validation: first and last name, email, an optional 10-digit US phone number and a deliverable ZIP code.
- Success: "You’re in Batch 6, Sam." with a reference
BB-<batch>-<4 characters>from a hash of the email and the date (reservationRef(); sam@example.com on October 31, 2026 givesBB-06-26CD), the ship month, the $150 refundable deposit and what happens next for the delivery zone. The reservation is saved undersprocket:reservation. - Return visit: the saved reservation is shown instead of the form, with "Change my build" and "Cancel reservation" (a second "Yes, cancel it" step). Cancelling removes it and shows the form again with "Reservation BB-06-26CD canceled. Your $150 deposit will be refunded in full."
No payment is taken: see Forms to connect a real backend or payment provider.
Cost calculator#
On /specs (app/pages/specs.vue, costFor(), settings in COST): car = miles a week × 52 × cost a mile +
parking × 12; Portage = miles × 52 × 15 Wh ÷ 1000 × $0.16 + $240 upkeep, plus the $3,895 base price over 6 years.
Payback = base price ÷ ((car − Portage running costs) ÷ 12), shown as months, "Over 10 years" when longer or when
riding costs more. Defaults (40 mi, $0.70, $0 parking): car $1,456, Portage $894 ($245 running), you keep
$562 a year, payback 39 months.
Build log and articles#
/journal renders POSTS with a category filter (chips with aria-pressed; the choice is kept in ?cat=).
/article?a=<slug> renders one post; a missing slug shows the latest post, an unknown one shows the latest post
with a notice. The tab title and meta description follow the post once the page has loaded. Both use
PostCard.vue for the cards.
Demo dates#
Demo sites stay online for years, so nothing dated goes stale:
- Batches are worked out from today (see Production batches).
- Journal dates: with
DEMO.KEEP_DEMO_CURRENTon, every post date moves forward by the whole weeks elapsed sinceDEMO.DEMO_ANCHOR(2026-10-31) inpostsOn(), so weekdays stay right and the newest post stays recent. On the anchor date nothing moves. For a real site, setKEEP_DEMO_CURRENT: falseand write real dates. - Copyright year: the footer's
data-yearfollows today. - Previewing: add
?now=YYYY-MM-DDTHH:MMto any page (Central time) to see it on another date, for example/?now=2027-03-02T09:00. The configurator keeps the parameter in its URL; links to other pages drop it, like a fresh visit.
Team and roles#
/about renders PEOPLE as cards with portraits and ROLES filtered by TEAMS (chips; the filter is kept in
?team=). Each role's "Apply" links to /support?topic=careers#contact, which preselects the topic.
Support page#
The FAQ search ([data-faq-search]) hides questions that don't contain the text and shows "3 questions match" or a
"No questions match" message. The ZIP checker uses the same zoneFor() as the reservation. The contact form reads
?topic= (one of the select's values) to preselect a topic, and "Send another message" keeps it.
Header, menu, reveal and back to top#
The header gets a shadow once you scroll. Below 1024px a menu button opens the mobile menu (aria-expanded;
Escape closes it and returns focus to the button; following a link closes it). Elements with .reveal fade in when
scrolled into view (app/plugins/reveal.client.ts), off under reduced motion; don't put .reveal on an element
that also has a :class binding. BackToTop.vue appears after a screen of scrolling.
Links to the same page#
In the HTML version every link loads a page. Here links are client-side navigations, so
app/plugins/same-page.client.ts and the page key in app/app.vue make a link to the page already showing (the
menu link to the page you are on, "Build log" on a filtered journal, another article from an article) start that
page afresh at the top, like a page load.
Forms#
The demo forms validate in the browser and show a success state, but send nothing until you connect them.
| Form | Fields | Validation |
|---|---|---|
Reservation ([data-reserve-form], reserve.vue) |
first, last, email, phone (optional), zip, updates | names required, email format, 10-digit phone if given, deliverable ZIP |
Contact ([data-contact-form], support.vue) |
name, email, topic, ref (optional), message | name, email format, reference like BB-06-K7QD if given, message 10+ characters |
ZIP checker ([data-zip-check], support.vue) |
zip | 5 digits |
To connect the contact form to a service such as Formspree, find the comment "To receive messages" in send() in
app/pages/support.vue and post the fields before showing the success state (send() is already async):
const res = await fetch("https://formspree.io/f/your-id", {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(contact.value!),
});
if (!res.ok) return; // show an error and keep the formWith a server build (pnpm build, see Building and deploying) you can post to your own
route instead, for example server/api/contact.post.ts with export default defineEventHandler(async (event) => …)
that reads await readFormData(event) and sends an email.
For real reservations, replace the code after the comment "To take real reservations" in onSubmit() in
app/pages/reserve.vue with a call to your backend or a payment provider's checkout for the $150 deposit, and show
the success state only when it confirms. Keep the line about the deposit being refundable accurate to your terms.
Saved data (localStorage)#
| Key | Shape | Validation on read |
|---|---|---|
sprocket:build |
{ "color": "harbor", "battery": "dual", "acc": ["rack", "kidseat"] } |
color must be a key of BUILD.colors, then normalizeBuild() |
sprocket:reservation |
{ ref, first, last, email, phone, zip, updates, build, total, batch, shipNo, created } |
reference pattern, strings, integer batch and month number, valid created date, build normalized |
All access goes through readBuild() and readReservation() in app/utils/store.ts and safeStorage in
app/utils/safeStorage.ts: localStorage when available, otherwise an in-memory stand-in, so saving and reserving
still work for the page view in a sandboxed iframe or with storage blocked. Pages read storage in onMounted(), so
the generated HTML never depends on it. To reset, use "Forget it" and "Cancel reservation", or clear the site's
storage in the browser's developer tools.
SEO and sharing#
- Each page calls
usePageSeo({ title, description, page })(app/composables/usePageSeo.ts), which sets the title, description, canonical link, Open Graph tags (with the imageimg/og-image.png), the Twitter card and<body data-page>. The 404 page passesnoindex: true(robotsnoindex, no canonical). - Canonical,
og:urlandog:imageare absolute:SITE_URLplus the base path, with clean URLs (/bike, not/bike.html). Build with your domain so social images get absolute URLs (the default ishttps://bonfield.example):
SITE_URL=https://www.yourbikes.example pnpm generate/article updates the tab title and description for the post shown. Configure 404.html as your host's
not-found page (most static hosts use it automatically). Add a public/robots.txt and a sitemap when you deploy.
If you add Product structured data (with useHead()), use your real prices and availability.
Accessibility#
- Skip link, landmarks (
header, labelednavs,main,footer), one<h1>per page and a logical heading order. - Visible focus styles; the color swatches are a radio group with arrow-key support; filters are buttons with
aria-pressed; options are real radios and checkboxes. - Live regions announce the hero color, accessory notes, build status, range result, ZIP results and counts.
- Form errors are linked with
aria-describedbyand fields getaria-invalid; the first invalid field is focused; success states receive focus. - The bike drawing has an
aria-labeldescribing the current build; decorative SVG isaria-hidden. - Reveals, transitions and the back-to-top scroll respect
prefers-reduced-motion. Text colors meet WCAG AA.
When editing, keep one <h1> per page, label every new input and keep aria-controls/aria-describedby ids in
step with the markup.
Building and deploying#
pnpm generate # static site in .output/public/
pnpm preview # serve it locallyDeploy .output/public/ to any static host (Netlify, Cloudflare Pages, GitHub Pages, S3) or import the repository
into Vercel or Netlify, which detect Nuxt. Pages are written as bike.html, range.html and so on, and links
point to /bike without a trailing slash; most static hosts serve /bike from bike.html automatically, and
404.html is the not-found page. Every page is listed in routes in nuxt.config.ts; add new pages there so
they are generated.
Tailwind is compiled by @tailwindcss/vite during the build, so there is no separate CSS step. It scans app/
(and nuxt.config.ts for the <body> classes), including class names inside strings in components and
app/utils/art.ts, so write class names out in full.
For a site in a sub-folder, set DEMO_BASE_PATH when building (DEMO_BASE_PATH=/bikes pnpm generate): it becomes
app.baseURL in nuxt.config.ts, and every <NuxtLink> and asset URL includes it. pnpm build:demo does this
for the freeframe.dev demo and writes demo/, which you can delete.
Need server features (real reservations, a form handler)? pnpm build makes a Node.js server build in
.output/ (pages are still prerendered); add routes in server/api/ and deploy to a Node-capable host.
pnpm lint runs ESLint 10 with @nuxt/eslint. tsconfig.json only references the configs Nuxt writes to
.nuxt/, so pnpm typecheck checks the app and config with Nuxt's strict settings. typescript is aliased to
@typescript/typescript6, the TypeScript 6 API that vue-tsc needs.
Troubleshooting#
- The batch, spots left or dates look wrong. They are worked out in Central time from today; check for a
leftover
?now=in the URL, or use?now=2027-01-15T09:00to check another date, and editBATCHESinapp/data/site.ts. - The page briefly shows Batch 6 or the standard Portage. That is the text written for the static build; it is replaced as soon as the page loads. If it stays, check the browser console for a JavaScript error.
- A shared configurator link opens the default bike. Check the parameter values: they must be keys from
BUILD(?color=birch, not?color=Birch White). - The saved build or reservation disappears on reload. The browser is blocking storage (private mode, a sandboxed iframe); it then only lasts for the page view.
- An accessory doesn't appear on the bike. It needs a
<g data-part="<key>">group inBikeArt.vue. - Copy link does nothing. Clipboard access needs
https://orlocalhost; on other origins the page shows the link in a field to copy instead. - A Tailwind class has no effect. Tailwind only generates classes it finds written out in the source; a class
built from pieces (
`bg-${color}`) is never found. - Links or images break when the site is in a sub-folder. Build with
DEMO_BASE_PATH, write internal links as<NuxtLink to="/page">and referencepublic/files with$config.app.baseURL.
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. Fonts: Kanit and
Encode Sans are licensed under the SIL Open Font License. Bonfield Bikes, the Portage, its people, press
quotes, backers, phone number and email address are fictional placeholders.