Overview#
Keystone is a multi-page website template for a construction and architecture firm that designs and builds under
one contract. The demo brand is Corbel Design-Build, a fictional Milwaukee general contractor with an in-house
architecture practice (Corbel Studio), founded in 1987. The design is architectural and technical: drawing-paper and
blueprint grids, SVG line drawings with dimension lines, condensed uppercase headings and a safety-orange accent on
charcoal and concrete tones. This is the Nuxt version: Nuxt 4, Vue 3 single-file components, TypeScript and Tailwind
CSS v4, generated as a fully static site, with no other dependencies. This guide covers version 1.0.0 (see
template.json). Live demo: freeframe.dev/templates/keystone.
Quick start#
Requirements: Node.js 24 and pnpm (corepack enable pnpm).
pnpm install
pnpm dev # http://localhost:3000| Command | What it does |
|---|---|
pnpm dev |
Development server at http://localhost:3000 |
pnpm generate |
Static site in .output/public/ (every page prerendered, plus 404.html) |
pnpm preview |
Serve the last build locally |
pnpm build |
A Node.js server build in .output/ (pages are still prerendered) |
pnpm lint |
ESLint (flat config from @nuxt/eslint) |
pnpm typecheck |
nuxt prepare, then type-check with vue-tsc |
pnpm build:demo |
Build for /demos/nuxt/keystone into demo/ (the freeframe.dev demo; you can delete it) |
pnpm install runs nuxt prepare, which writes the .nuxt/ folder that TypeScript and ESLint read.
Pages#
| Route | File | Purpose and notable features |
|---|---|---|
/ |
app/pages/index.vue |
Home: hero with a self-drawing SVG building and crane, count-up stats, four services, featured projects, process timeline, safety and accreditation strip, client references, call-to-action band |
/projects |
app/pages/projects.vue |
Portfolio: nine project cards with type filter chips, live search, result count, empty state and shareable URLs |
/project |
app/pages/project.vue |
Case study (Malt House Lofts): facts table, challenge and solution, before/after slider, drawing gallery with lightbox, outcome numbers, related projects |
/services |
app/pages/services.vue |
Four service sections with scope and typical costs, sectors, three contract models, FAQ accordion |
/estimate |
app/pages/estimate.vue |
Five-step estimator (type, size, finish, timeline, your details) with a live cost range, build time and breakdown, and the quote request form |
/about |
app/pages/about.vue |
Story and milestones, values, leadership team, safety record with an SVG bar chart, open roles |
/contact |
app/pages/contact.vue |
Contact form, two offices with hours and live open/closed status, SVG regional map, direct email lines |
| 404 | app/error.vue |
Keystone-and-arch line drawing and links back into the site (generated as 404.html) |
Project structure#
keystone/
app/
app.vue <NuxtLayout><NuxtPage>, with a page key so links to the open page restart it
error.vue the 404 page
router.options.ts writes query strings the way the HTML version does
layouts/default.vue skip link, header, the page's <main>, footer and the back-to-top button
pages/ one .vue file per page (index, projects, project, services, estimate, about, contact)
components/
SiteHeader.vue license bar, sticky header, main and mobile menus
SiteFooter.vue footer columns, head office, the CORBEL wordmark and the copyright year
CtaBand.vue orange "Next step" band (the heading is the slot)
ProjectCard.vue portfolio card
ProjectsBrowser.vue filters, search, count and empty state
ProjectEstimator.vue the estimator: steps, choices, size, summary and the phone price bar
OptionCard.vue one estimator choice (a radio button styled as a card)
QuoteForm.vue estimator step 5: the quote request and its confirmation
ContactForm.vue the contact form and its confirmation
CountUp.vue a figure that counts up when it scrolls into view
BackToTop.vue back-to-top button
LineIcon.vue, LogoMark.vue line icons and the Corbel mark
composables/
useFormCheck.ts form validation shared by both forms
usePageSeo.ts title, description, canonical and Open Graph tags per page
useCurrentPage.ts aria-current for menu links
useAsset.ts asset(): prefixes public/ files with the base path
useYear.ts the copyright year
data/
site.ts company name, phone, email and license number
nav.ts header, mobile menu and footer links
home.ts, about.ts, services.ts, contact.ts page content (stats, team, FAQ, offices and hours...)
projects.ts the nine projects, the case study facts, outcomes and gallery
estimator.ts estimator price model
icons.ts line icon paths
utils/
estimate.ts estimate calculation, formatting and the saved estimate
officeStatus.ts the open/closed text for an office
safeStorage.ts localStorage with an in-memory fallback
plugins/
reveal.client.ts scroll reveals and self-drawing line art
same-page.client.ts links to the page you are on start it again at the top
stable-build.server.ts reproducible builds
assets/css/main.css Tailwind entry: fonts, @theme tokens and component classes
assets/fonts/ woff2 files and their licenses
public/img/ SVG artwork, favicon and the social image
nuxt.config.ts base path, head defaults, SITE_URL, prerendered routes, Tailwind
template.json, package.json, README.md, DOCS.md, CHANGELOG.mdPages are prerendered to plain HTML; Vue then takes over in the browser for the interactive parts.
Customizing#
Brand and colors#
All design tokens live in the @theme block of app/assets/css/main.css. Three color families matter:
| Token family | Used for |
|---|---|
--color-char-950 … --color-char-500 |
Charcoal: dark sections, header border, text |
--color-concrete-50 … --color-concrete-400 |
Concrete: page background, borders, drawing grids |
--color-signal-300 … --color-signal-700 |
Signal orange: buttons, highlights, accents |
Use signal-500 as a fill (with char-950 text on it) and as text on charcoal. Use signal-700 for orange text on
light backgrounds; signal-500 text on white does not meet WCAG AA contrast.
To change the accent, edit the signal values:
@theme {
--color-signal-300: #7cc4ff;
--color-signal-400: #3fa5ff;
--color-signal-500: #1488f0;
--color-signal-600: #0b6fcc;
--color-signal-700: #0a5299;
}A few colors are written directly into SVG artwork as hex values such as #f15a0f: the hero drawing
(app/pages/index.vue), the map (app/pages/contact.vue), the safety chart (app/pages/about.vue), the 404
drawing (app/error.vue), the logo mark (app/components/LogoMark.vue) and the files in public/img/. Search and
replace them if you change the accent. The browser theme color (#1a1b1d) is the theme-color meta tag in
nuxt.config.ts.
Useful component classes in main.css:
| Class | What it does |
|---|---|
.btn + .btn-signal, .btn-dark, .btn-ghost, .btn-ghost-light, .btn-lg |
Square-cornered uppercase buttons |
.eyebrow, .eyebrow-light |
Mono section label with an orange square |
.h-display |
Condensed uppercase display heading |
.spec |
Small mono uppercase text for specs and labels |
.grid-paper, .grid-blueprint |
Light drawing-paper and dark blueprint grid backgrounds |
.crop |
Orange crop marks on two corners of a card |
.chip, .option-card, .input, .label, .field-error |
Filters, estimator choices and form fields |
.container-x |
The page container (max-w-7xl with side padding) |
Typography#
Three families are self-hosted from app/assets/fonts/ with @font-face rules at the top of main.css (the Latin
files Google Fonts serves, under the SIL Open Font License):
- Barlow Condensed (500–700) for headings, buttons and navigation:
--font-display. - Barlow (400–600, plus 400 italic) for body text:
--font-sans. - IBM Plex Mono (400–500) for technical labels:
--font-mono.
Each keeps its real family name, so the tokens and the SVG drawings (font-family="IBM Plex Mono, ...") can refer to
it directly. To swap a font, add its woff2 files to app/assets/fonts/, add @font-face rules for them and change the
token in @theme:
--font-display: "Oswald", "Arial Narrow", ui-sans-serif, system-ui, sans-serif;Content#
Page copy is written in each page's .vue file. Lists that repeat or drive a feature live in app/data/:
| File | Export | Used by |
|---|---|---|
site.ts |
site |
Top bar, footer, call-to-action band |
nav.ts |
mainNav, mobileNav, footerNav, NAV_PARENT |
Header and footer |
home.ts |
STATS, SERVICE_CARDS, PROCESS, CREDENTIALS, TESTIMONIALS |
Home page |
projects.ts |
PROJECTS, PROJECT_TYPES, gallery, CASE_STUDY_FACTS, CASE_STUDY_OUTCOMES |
Projects page, case study |
services.ts |
SERVICES, SECTORS, CONTRACT_MODELS, FAQ |
Services page |
about.ts |
MILESTONES, VALUES, TEAM, SAFETY_FIGURES, JOBS, CAREERS_EMAIL |
About page |
contact.ts |
OFFICES, TOPICS, DIRECT_LINES, OFFICE_HOURS |
Contact page |
estimator.ts |
TYPES, FINISH, TIMELINE, DESIGN, DEFAULT_ESTIMATE |
Estimator and quote request |
Projects. Each project is one object; the card, its filter chip count and the search text are all worked out from it:
{ slug: "cedarburg-mill", code: "P-117", name: "Cedarburg Mill", type: "renovation", place: "Cedarburg, Wisconsin", area: 3400, completed: "2023",
summary: "An 1855 stone grist mill reborn as a brewery and event hall, joined to a new glass link." },slug names the drawing (public/img/project-<slug>.svg), type is one of the keys of PROJECT_TYPES
(commercial, residential, renovation or architecture) and area is in m² (the card adds ft² with
areaFt2()). The search matches the name, place, type, year and summary (searchText()). To add a type, add it to
PROJECT_TYPES; a chip appears for it. The featured cards on the home page are written into app/pages/index.vue.
Case studies. /project is one complete case study. For more, copy app/pages/project.vue to a new page (for
example app/pages/third-ward.vue), replace the facts, text and images, add its route to routes in
nuxt.config.ts, and point that project's card link (ProjectCard.vue links every card to /project) at it.
Count-up numbers. Stats use <CountUp :value="620" :decimals="0" />. The generated HTML holds the final value,
which is what visitors see without JavaScript or with reduced motion.
Team and roles are TEAM and JOBS in app/data/about.ts; the Apply buttons email CAREERS_EMAIL with the role
as the subject. The safety chart is an inline SVG in app/pages/about.vue, with a screen-reader table next to it;
change both together.
Images and artwork#
There are no photos: every image is an SVG designed as a technical drawing, in public/img/.
| Artwork | Size / ratio | Replace with |
|---|---|---|
project-*.svg |
800 x 600 (4:3) | Project photos at 4:3, at least 1200 x 900 |
malt-house-before.svg, malt-house-after.svg |
800 x 520 | A matching pair of photos from the same viewpoint and size |
malt-house-section.svg, malt-house-interior.svg, malt-house-site.svg |
800 x 600 | Gallery photos or drawings at 4:3 |
team-*.svg |
400 x 480 (5:6) | Portraits at 5:6 |
| Hero, map, chart and 404 drawings | Inline SVG in the pages | Edit the SVG or swap for an <img> |
Images are plain <img> elements with :src="asset('img/...')": asset() adds the base path when the site is
served from a sub-folder. Cards crop with object-cover, so other ratios still fit, but 4:3 looks best. When you
switch to JPEG or WebP, keep width and height in step with the real size and write a meaningful alt.
The hero drawing and the 404 drawing use the class draw. Every path inside has pathLength="1", which lets the CSS
animate the stroke from 0 to 1; elements with the class fade fade in after the lines. Give your own lines
pathLength="1" too.
The social image is public/img/og-image.png (1200 x 630), exported from public/img/og-image.svg. The favicon is
public/img/favicon.svg.
Navigation, header and footer#
The header is app/components/SiteHeader.vue and reads its menus from app/data/nav.ts:
export const mainNav: NavLink[] = [
{ href: "/projects", label: "Projects" },
{ href: "/services", label: "Services" },
{ href: "/about", label: "About" },
{ href: "/contact", label: "Contact" },
];mobileNav adds Home and "Get an estimate" around it. To add a page, create it in app/pages/, add its route to
routes in nuxt.config.ts and add a link to mainNav. The current page is marked with aria-current="page"
(useCurrentPage()); pages listed in NAV_PARENT mark their section with aria-current="true" (the case study marks
Projects). The license bar above the header uses site.license and site.phone. The footer columns are footerNav;
the large "CORBEL" wordmark is an aria-hidden inline SVG in SiteFooter.vue.
Features#
Project estimator#
/estimate walks through five steps and shows a live estimate in the summary panel (aria-live="polite"), with a
slim fixed price bar on small screens.
-
Where the code is:
app/components/ProjectEstimator.vue(steps, choices and summary),app/data/estimator.ts(the price model) andapp/utils/estimate.ts(the calculation and saved state). -
The data:
tsexport const TYPES = { commercial: { label: 'Commercial build', desc: '...', rate: [215, 340], min: 2000, max: 80000, step: 500, size: 12000, weeks: [26, 1], help: '...' }, ... }; export const FINISH = { standard: { ..., meta: 'Base rate', mult: [1, 1] }, premium: { ..., meta: '+18–28%', mult: [1.18, 1.28] }, ... }; export const TIMELINE = { flexible: { ..., mult: [0.96, 0.97], pace: 1.15 }, standard: { ... }, fast: { ..., mult: [1.08, 1.14], pace: 0.82 } }; export const DESIGN = { label: 'Architecture & permits', mult: [1.07, 1.1], weeks: [12, 20] } as const; export const DEFAULT_ESTIMATE: Estimate = { type: 'commercial', size: 12000, finish: 'standard', timeline: 'standard', design: true }; -
The calculation (
estimate(s)inapp/utils/estimate.ts): floor area x the type'srateper ft², times the finish and timeline multipliers, timesDESIGN.multwhen architecture is included. The low end is rounded down and the high end up to the nearest $5,000. Build time in weeks is(weeks[0] + weeks[1] x size / 1000) x pace, shown as a range of −8% to +10%.compact()formats amounts as$640kor$3.08M. -
Changing prices: edit
rateandmult. The option cards show the type'srateand each option'smetatext, so updatemetawhen you change a multiplier. -
Adding a project type, finish or timeline: add a key to
TYPES,FINISHorTIMELINE; a card appears in its step. Size limits, step, starting size and help text come fromTYPESand apply whenever the type changes. -
Size: the slider and the number box stay in step. The box updates the estimate while you type a size within range and clamps to the type's limits when it changes (on blur or Enter).
-
Links in:
/estimate?type=renovationpreselects a type (used by the services and case study pages), and/estimate#requestopens the details step. -
Accessibility: each step is a
<fieldset>with a<legend>; choices are real radio buttons and a checkbox, so arrow keys and Space work. The step buttons usearia-current="step". Moving between steps with the buttons puts focus on the new step's heading.
Projects filter and search#
app/components/ProjectsBrowser.vue filters PROJECTS by type (chips with aria-pressed) and matches every typed
word against the search text (straight and curly apostrophes match each other). The count (role="status")
announces "Showing 2 of 9 projects", an empty state with a "Clear filters" button appears when nothing matches, and
the URL updates to ?type=...&q=... with history.replaceState, so filtered views can be shared (the services page
links to /projects?type=commercial and so on). Unknown type values are ignored. The URL is read after the page
loads, so the generated HTML shows all nine projects.
Before/after slider#
In app/pages/project.vue the after image sits underneath and the before image is clipped on top with
clip-path: inset(...) driven by the --pos custom property (.before-after-clip in main.css, set from the pos
ref). An invisible <input type="range"> covers the images, so dragging, clicking, touch and arrow keys all work, and
screen readers hear aria-valuetext such as "52% before, 48% after". Use two images of the same size.
Gallery lightbox#
The case study lists gallery (app/data/projects.ts) and opens each sheet in a native <dialog> with
showModal(): previous and next buttons, arrow keys (wrapping round), Esc or a click on the backdrop to close, and
focus back on the sheet that opened it. The code is openShot() and show() in app/pages/project.vue.
FAQ accordion#
app/pages/services.vue renders FAQ as buttons with aria-expanded and aria-controls, each answer a
role="region" panel toggled with hidden. Several answers can be open at once; the first starts open (the open
ref: change i === 0 to false to start closed).
Office open/closed status#
Each office card on /contact shows "Open now · until 5:30 pm" or "Closed · opens tomorrow 7:00 am", worked out in
the office's own time zone with Intl.DateTimeFormat (officeStatus() in app/utils/officeStatus.ts), from
OFFICE_HOURS in app/data/contact.ts:
milwaukee: { tz: 'America/Chicago', days: [null, [7, 17.5], [7, 17.5], [7, 17.5], [7, 17.5], [7, 16], null] },Day 0 is Sunday; null means closed; hours are decimals (17.5 is 5:30 pm). Keep days in step with the hours
rows of the same office in OFFICES. The status depends on the visitor's clock, so the generated HTML says "Office
hours" until the page runs in the browser.
Count-up stats, scroll reveals and line drawings#
app/plugins/reveal.client.ts runs on every page: elements with .reveal fade and rise into view and .draw SVGs
draw their lines. Anything above a revealed element is revealed too, so jumping with the End key or an anchor never
leaves hidden content. CountUp.vue counts its figure up once when it appears. All of it is skipped when the
visitor prefers reduced motion, and content is only hidden once a small script in nuxt.config.ts has added the js
class to <html>, so nothing disappears without JavaScript. The plugin adds is-visible with classList, so don't
put .reveal on an element whose class is bound to changing state.
Header, mobile menu and back to top#
The header is sticky and gains a shadow after scrolling. Below the lg breakpoint the menu button toggles
#mobile-menu, updating aria-expanded and its label; Esc closes it and returns focus to the button, and it closes
after choosing a link or widening the window. app/components/BackToTop.vue shows a button after about one screen of
scrolling; it steps aside while the footer's "Back to top" link is visible and lifts above the estimator's price bar,
which has data-back-to-top-avoid. To remove it, delete <BackToTop /> from app/layouts/default.vue and the
.back-to-top rules in main.css.
Forms#
There are two forms. Both validate in the browser and neither sends anything until you connect it. Their
success panels confirm the request the way a real office would: the quote request shows a reference like
CDB-26-1234 and says a project director will reply within one business day to book a site walk; the contact form
says which office the message reached, promises a reply within one business day and shows a reference like CB-7K2Q
(made by makeRef() in ContactForm.vue).
| Form | File | Validates |
|---|---|---|
| Quote request | app/components/QuoteForm.vue (estimator step 5) |
Name, email, phone (10+ digits), site, ideal start, project notes (20+ characters), consent box |
| Contact | app/components/ContactForm.vue |
Name, email, optional phone (10+ digits if filled), topic, nearest office, message (10+ characters) |
Validation is useFormCheck() in app/composables/useFormCheck.ts. It reads standard attributes (required,
type="email", minlength, required checkboxes) and data-validate:
data-validate |
Rule |
|---|---|
phone |
At least 10 digits |
phone-optional |
At least 10 digits if anything is typed |
A <fieldset data-group="name"> of radio buttons requires one to be chosen. Errors appear under each field as
.field-error, linked with aria-describedby, and the field gets aria-invalid="true". On a failed submit, focus
moves to the first invalid field; a filled-in field is checked when you leave it, and invalid fields are checked again
as they are corrected. To add a field, give it an id, its rules as attributes, the two bindings and the error
paragraph:
<div><label for="c-company" class="label">Company</label><input id="c-company" name="company" class="input" required :aria-invalid="v.invalid('c-company')" :aria-describedby="v.describedBy('c-company')"><p v-if="v.errors['c-company']" id="c-company-error" class="field-error">{{ v.errors["c-company"] }}</p></div>Connecting to Formspree#
Each form calls onValid(form) only when everything is valid. Create a form at Formspree, then post from there. In
ContactForm.vue:
async function onValid(el: HTMLFormElement) {
const data = new FormData(el);
const res = await fetch("https://formspree.io/f/your-form-id", {
method: "POST",
body: data,
headers: { Accept: "application/json" },
});
if (!res.ok) return alert("Sorry, your message could not be sent. Please call us.");
// ...then show the success panel as the demo does.
}For the quote request, do the same in QuoteForm.vue. The estimator choices are not part of that form, so append
them from the estimate prop:
const data = new FormData(el);
const s = props.estimate;
const r = estimate(s);
data.append("type", s.type);
data.append("size_ft2", String(s.size));
data.append("finish", s.finish);
data.append("timeline", s.timeline);
data.append("design", s.design ? "yes" : "no");
data.append("estimate", `${compact(r.low)} – ${compact(r.high)}`);Both references are made in the browser at random; replace them with the ID your system returns, or remove them. If you handle requests differently, change the "within one business day" promises in the success text of both components to match.
With pnpm build (a Node.js server build instead of the static site) you can also post to a Nuxt server route, for
example server/api/contact.post.ts with export default defineEventHandler(async (event) => { const body = await readFormData(event); ... }), and call it with $fetch("/api/contact", { method: "POST", body: data }).
Saved data (localStorage)#
| Key | Shape | Written |
|---|---|---|
keystone-estimate-v1 |
{"type": "commercial", "size": 12000, "finish": "standard", "timeline": "standard", "design": true} |
On every estimator change |
readEstimate() in app/utils/estimate.ts validates the stored value: type, finish and timeline must be keys
of TYPES, FINISH and TIMELINE; size must be a number within the type's min and max (otherwise the type's
starting size is used); design must be a boolean. Anything else falls back to DEFAULT_ESTIMATE. When storage is
blocked or full (private browsing, sandboxed frames), safeStorage keeps the answers in memory for the visit. A
?type= in the URL overrides the saved type. The saved answers are read after the page loads, so the generated HTML
shows the default estimate.
To reset it, delete the key in your browser's developer tools (Application, Local Storage). If you change the shape
or rename types, change ESTIMATE_KEY (for example to keystone-estimate-v2) so old values are ignored. To stop
saving, remove the saveEstimate(state) call in commit() in ProjectEstimator.vue.
SEO and sharing#
- Each page calls
usePageSeo({ title, description })(app/composables/usePageSeo.ts), which sets the title, description, canonical link, Open Graph and Twitter tags. The 404 page passesnoindex. - Canonical URLs and the social image URL are absolute:
SITE_URLplus the base path. Set it when you build:
SITE_URL=https://www.your-domain.com pnpm generateWithout it, URLs fall back to https://corbel.example (runtimeConfig.public.siteUrl in nuxt.config.ts). For
launch, consider adding a sitemap and robots.txt (a static public/robots.txt works). For a real firm,
GeneralContractor structured data (JSON-LD) with your address, phone and
opening hours helps search engines show your details; add it with useHead() in app/layouts/default.vue.
Accessibility#
Built in:
- Skip link,
header/nav/main/footerlandmarks, one<h1>per page and a logical heading order. - Visible focus outlines in signal orange, including on custom radio cards and the before/after slider.
- Mobile menu with
aria-expanded,aria-controlsand Esc to close. - Filter chips with
aria-pressed, a live result count, accordion buttons witharia-expanded, estimator steps witharia-current="step"and a live estimate. - Native
<dialog>lightbox with keyboard support and focus return. - Labeled form fields, inline errors linked with
aria-describedby, and focus moved to the first error. - Meaningful
alttext on drawings; decorative SVG isaria-hidden. The safety chart has a screen-reader table. - Reduced motion turns off reveals, line drawing, count-ups and smooth scrolling.
- Text colors meet WCAG AA contrast.
When editing, keep one <h1> per page, give every new input a <label>, and keep orange text on light backgrounds at
signal-700.
Building and deploying#
pnpm generate writes a fully static site to .output/public/: one .html file per page (projects.html, served at
/projects), 404.html and the assets in _nuxt/. Deploy it anywhere:
- Netlify / Cloudflare Pages / Vercel: build command
pnpm generate, publish directory.output/public. - Any static host: upload the contents of
.output/public/. Configure the host to serveprojects.htmlfor/projects(most do) and404.htmlfor unknown URLs.
To serve the site from a sub-folder, build with a base path: DEMO_BASE_PATH=/keystone pnpm generate.
nuxt.config.ts passes it to app.baseURL; links get it automatically and public files through asset().
pnpm build:demo does this for the freeframe.dev demo and writes demo/, which you can delete.
Troubleshooting#
| Problem | Fix |
|---|---|
| The estimator shows old answers | They are saved in localStorage. Delete keystone-estimate-v1 or change ESTIMATE_KEY. |
| Estimator card prices don't match the summary | Card prices come from rate and each option's meta text in app/data/estimator.ts; update meta with the multiplier. |
| The office status says "Closed" during opening hours | Check days in OFFICE_HOURS (app/data/contact.ts: day 0 is Sunday, decimal hours) and the tz value for your city. |
| A filtered projects link shows everything | ?type= must be one of the PROJECT_TYPES keys; unknown types are ignored on purpose. |
| Images break under a sub-folder | Build with DEMO_BASE_PATH set to that folder and use asset() for files in public/. |
Social previews use corbel.example |
Set SITE_URL when building. |
A new page shows the 404 page after pnpm generate |
Add its route to routes in nuxt.config.ts. |
pnpm lint or pnpm typecheck can't find .nuxt/ |
Run pnpm install (or npx nuxt prepare) first. |
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. Barlow, Barlow
Condensed and IBM Plex Mono are included under the SIL Open Font License (app/assets/fonts/Barlow-OFL.txt,
app/assets/fonts/IBMPlexMono-OFL.txt). Corbel Design-Build and every person, project and address in the
template are fictional.