Freeframe

Documentation

Keystone

v1.0.0Updated Oct 12, 2026An architectural, high-trust site for a design-build construction firm, with a project estimator and case studies.

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 Astro version: Astro 7, TypeScript and Tailwind CSS v4, with no UI framework, built to plain static files. It has the same pages, design, copy and interactions as the HTML version. This guide covers version 1.0.0 (see template.json). Live demo: freeframe.dev/templates/keystone.

Quick start#

Requirements: Node.js 24 or newer and pnpm (npm works too).

sh
pnpm install
pnpm dev          # http://localhost:4321

With npm: npm install, then npm run dev.

Command What it does
pnpm dev Development server at http://localhost:4321 with hot reload
pnpm build Static site in dist/
pnpm preview Serve dist/ locally after a build
pnpm check astro check: type-checks .astro files and TypeScript
pnpm typecheck Generate Astro's types, then type-check with tsc (TypeScript 7)
pnpm lint ESLint (TypeScript and Astro rules)
pnpm build:demo Build for /demos/astro/keystone into demo/ (only used for the demo on freeframe.dev)

Pages#

Route File Purpose Notable sections and features
/ src/pages/index.astro Home Hero with a self-drawing SVG building and crane, count-up stats, four services, featured projects, process timeline (design, permits, build, handover), safety and accreditation strip, client references, call-to-action band
/projects src/pages/projects.astro Portfolio Nine project cards with type filter chips, live search, result count, empty state, shareable URLs
/project src/pages/project.astro Case study (Malt House Lofts) Facts table, challenge and solution, before/after slider, drawing gallery with lightbox, outcome numbers, related projects
/services src/pages/services.astro Services Four service sections with scope and typical costs, sectors, three contract models, FAQ accordion
/estimate src/pages/estimate.astro Project estimator Five steps: type, size, finish, timeline, your details; live cost range, build time and breakdown; quote request form
/about src/pages/about.astro Company Story and milestones, values, leadership team, safety record with an SVG bar chart, open roles
/contact src/pages/contact.astro Contact Contact form, two offices with hours and live open/closed status, SVG regional map, direct email lines
/404 src/pages/404.astro Not found Keystone-and-arch line drawing and links back into the site

Project structure#

keystone/
  astro.config.mjs        site URL, base path, static output (pages built as about.html)
  package.json            scripts and dependencies
  pnpm-workspace.yaml     marks the folder as a standalone pnpm project
  tsconfig.json           extends Astro's strict preset
  eslint.config.mjs       ESLint flat config (typescript-eslint + eslint-plugin-astro)
  public/img/
    favicon.svg           browser tab icon
    og-image.svg          social image source (1200 x 630)
    og-image.png          social image used in og:image
    project-*.svg         nine project elevation drawings (800 x 600)
    malt-house-*.svg      case study before/after views (800 x 520) and gallery sheets (800 x 600)
    team-*.svg            four leadership portraits (400 x 480)
  src/
    pages/*.astro         one file per page (see Pages)
    layouts/Layout.astro  <html>, <head> metadata, skip link, header, footer and the script imports
    components/
      SiteHeader.astro    top bar, sticky header, desktop and mobile menus
      SiteFooter.astro    footer columns, wordmark and "Back to top" link
      CtaBand.astro       the orange "Next step" band at the end of most pages
      ProjectCard.astro   a project card (portfolio and related projects)
      OptionCard.astro    a radio choice card in the estimator
    data/
      nav.ts              header, mobile menu and footer links
      home.ts             home page stats, service cards, process, credentials, testimonials
      projects.ts         project types, the nine projects, case study facts, outcomes and gallery
      services.ts         service sections, sectors, contract models, FAQ
      about.ts            milestones, values, team, safety figures, open roles
      contact.ts          offices, office hours, contact form topics, direct lines
      estimator.ts        the estimator price model (types, finishes, timelines, design fee, defaults)
    lib/
      site.ts             site name, phone, email, theme colour and the url() helper
      icons.ts            line icons as SVG strings, and the logo mark
      estimate.ts         estimator maths: estimate(), money(), compact(), pct(), clampSize()
    scripts/
      main.ts             all interactive behaviour (menus, reveals, filters, estimator, forms...)
      back-to-top.ts      the back-to-top button
    styles/global.css     Tailwind CSS v4: brand tokens and component classes
  template.json           gallery metadata for freeframe.dev
  README.md, DOCS.md, CHANGELOG.md

Customising#

Brand and colours#

All design tokens live in the @theme block of src/styles/global.css. Three colour 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. Astro rebuilds the CSS on save in pnpm dev and on every build:

css
@theme {
  --color-signal-300: #7cc4ff;
  --color-signal-400: #3fa5ff;
  --color-signal-500: #1488f0;
  --color-signal-600: #0b6fcc;
  --color-signal-700: #0a5299;
}

A few colours are written directly into SVG artwork as hex values such as #f15a0f: the hero drawing (index.astro), the map (contact.astro), the safety chart (about.astro), the 404 drawing, the logo mark (logoMark() in src/lib/icons.ts) and the files in public/img/. Search and replace them if you change the accent. The theme-color meta tag comes from site.themeColor in src/lib/site.ts (#1a1b1d).

Useful component classes in src/styles/global.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 Google Fonts are loaded by one <link> in src/layouts/Layout.astro:

  • Barlow Condensed (500–700) for headings, buttons and navigation: --font-display.
  • Barlow (400–600) for body text: --font-sans.
  • IBM Plex Mono (400–500) for technical labels: --font-mono.

Each token has a system fallback stack, so the site still reads well if the fonts don't load. To swap a font, change the Google Fonts URL in the layout and the matching token in @theme:

css
--font-display: "Oswald", "Arial Narrow", ui-sans-serif, system-ui, sans-serif;

Content#

Page copy that appears once (headings, intros, the case study text) is written in the page's .astro file. Anything that repeats is typed data in src/data/ and rendered with .map(), so adding an item is one line.

Site details. The company name, phone number, email and licence number shown in the top bar, header, footer and CTA band come from site in src/lib/site.ts:

ts
export const site = {
  name: "Corbel Design-Build",
  themeColor: "#1a1b1d",
  phone: "(414) 555-0187",
  phoneHref: "tel:+14145550187",
  email: "hello@corbel.example",
  licence: "WI GC #DC-1142079",
};

Projects. PROJECTS in src/data/projects.ts lists the portfolio in display order:

ts
{ 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 picks the drawing public/img/project-<slug>.svg; alt is optional and defaults to "Elevation drawing of <name>".
  • type is a key of PROJECT_TYPES (commercial, residential, renovation, architecture). The filter chips and their counts are built from PROJECT_TYPES and PROJECTS; add a key there to add a filter.
  • area is the floor area in m²; the card also shows it in ft² (areaFt2()).
  • The search text (data-text) is built by searchText() from the name, place, type, year and summary.

ProjectCard.astro renders a card; it is used on the portfolio and for the related projects on the case study (the related list at the top of project.astro). The three featured projects on the home page are written out in index.astro because they use a different layout.

Case study. project.astro is one complete case study, Malt House Lofts. Its facts panel, outcome numbers and gallery come from CASE_STUDY_FACTS, CASE_STUDY_OUTCOMES and gallery in src/data/projects.ts. The gallery list drives both the thumbnails and the lightbox (it is written into the page as JSON in [data-lightbox-data]):

ts
export const gallery = [
  { src: "/img/project-malt-house-lofts.svg", title: "North elevation", caption: "Restored brick, steel windows and the rebuilt water tower." },
  ...
];

For more case studies, copy project.astro (for example to project-third-ward.astro), change its text and data, and point that project's card at it.

Services. src/data/services.ts holds SERVICES (the four sections on the services page, with their checklist, three key facts, image and links), SECTORS, CONTRACT_MODELS and FAQ. The home page's four service cards are SERVICE_CARDS in src/data/home.ts; their anchor is the matching section id on the services page.

Home page. src/data/home.ts also holds STATS (hero figures), PROCESS (the four-step timeline), CREDENTIALS (the safety and accreditation strip) and TESTIMONIALS.

About page. src/data/about.ts holds MILESTONES, VALUES, TEAM, SAFETY_FIGURES and JOBS. Adding a role:

ts
{ title: "Site engineer", place: "Madison", hours: "Full-time", text: "Set out and check every structure we build." },

Its Apply button opens an email to CAREERS_EMAIL with "Application: Site engineer" as the subject. A team member needs a portrait at public/img/team-<photo>.svg.

Contact page. src/data/contact.ts holds OFFICES (address, phone, email, hours table and note for each office), TOPICS (the contact form's topic options), DIRECT_LINES and OFFICE_HOURS (see Office open/closed status).

Count-up numbers. Stats render as <span data-count="620" data-decimals="0">620</span>. The final value is the text inside the span: that is what visitors see without JavaScript or with reduced motion.

Images and artwork#

There are no photos: every image is an SVG designed as a technical drawing, served from 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 .astro pages Edit the SVG or swap for an <img>

Cards crop images with object-cover, so other ratios still fit, but 4:3 looks best. If you switch to JPEG or WebP, update the file names in the data (ProjectCard.astro builds project-<slug>.svg), keep the width and height attributes in step with the real size and write a meaningful alt. Image paths always go through url(), for example url("/img/team-lena.svg"), so they work under a base path.

The hero drawing on the home page and the 404 drawing use the class draw. Every path and line inside it has pathLength="1", which lets the CSS animate the stroke from 0 to 1. Elements with the class fade fade in after the lines. If you add your own lines to a .draw SVG, give them pathLength="1" too.

The social image is public/img/og-image.png (1200 x 630). Its source is og-image.svg; edit it, then export it to PNG with any SVG editor or a browser screenshot at 1200 x 630. The favicon is public/img/favicon.svg.

Icons are SVG strings from icon(name, classes, strokeWidth) in src/lib/icons.ts, rendered with <Fragment set:html={icon("arrow", "size-5")} />. Add an icon by adding its paths to the paths object.

The header and footer are components, so there is one place to change them:

  • Links: src/data/nav.ts. mainNav is the desktop menu, mobileNav the mobile menu (Home, the main links and "Get an estimate"), and footerNav the footer's Work and Company columns. Write paths without .html ("/about#careers"); the components pass them through url().
  • Current page: each page passes current to <Layout> (current="services"), which sets aria-current="page" on the matching links; the underline and colour come from the aria-[current=page] styles on .nav-link and .mobile-link. The case study passes section="projects" instead, which sets aria-current="true" because it is inside that section. The "Get an estimate" button in the header is shown from the sm breakpoint up and is marked current on the estimator.
  • Top bar and footer: the top bar (licence and phone) is at the top of SiteHeader.astro. The footer's large "CORBEL" wordmark is an aria-hidden inline SVG in SiteFooter.astro; change its <text> or remove the whole <div aria-hidden="true">.

To add a page, create src/pages/<name>.astro with <Layout title=... description=... current="<name>">, put the content in <main id="main">, and add it to nav.ts.

Features#

All behaviour is in src/scripts/main.ts (and back-to-top.ts), imported once from the layout. Each feature looks for its own data- attributes and does nothing on pages without them.

Project estimator#

/estimate walks through five steps and shows a live estimate in the summary panel (aria-live="polite"), with a slim sticky price bar on small screens.

  • Where the code is: the price model is src/data/estimator.ts, the maths is src/lib/estimate.ts, and the UI is the "Estimator: 4 steps + request form" block in main.ts.

  • The data:

    ts
    export 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: { ..., mult: [1, 1] }, premium: { ..., mult: [1.18, 1.28] }, signature: { ..., mult: [1.45, 1.65] } };
    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)): floor area x the type's rate per ft², times the finish and timeline multipliers, times DESIGN.mult when 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 $640k or $3.08M.

  • Changing prices: edit rate and mult. The option cards (rendered by OptionCard.astro from TYPES, FINISH and TIMELINE), the size limits and the figures in the summary panel before the script runs are all built from the same data, so they update together. For finishes and timelines, also update the meta text shown on the card ('+18–28%').

  • Adding a project type: add a key to TYPES. Its card appears in step 1, and its size limits, step, default size and help text are applied whenever it is chosen.

  • Links in: /estimate?type=renovation preselects a type (used by the services and case study pages), and /estimate#request opens 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 use aria-current="step". Moving between steps puts focus on the new step's heading. Without JavaScript all steps are shown at once.

On /projects, the chips filter by data-type and the search box matches every typed word against data-text. 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 or linked to (the services page links to /projects?type=commercial and so on). Chips are buttons with aria-pressed. The code is the "Projects: type filter + search" block in main.ts.

Before/after slider#

On /project, 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 global.css). An invisible <input type="range"> covers the image, so dragging, clicking, touch and arrow keys all work, and screen readers get aria-valuetext such as "52% before, 48% after". The visible handle and the focus ring follow the range. To use it elsewhere, copy the [data-before-after] block and use two images of the same size.

The gallery on /project opens images in a native <dialog> ([data-lightbox]) with previous and next buttons, arrow keys, Esc to close, a click on the backdrop to close and focus returned to the button that opened it. Image data comes from gallery in src/data/projects.ts (see Content).

FAQ accordion#

/services uses buttons with aria-expanded and aria-controls inside [data-accordion]; each answer is a role="region" panel toggled with the hidden attribute. Several answers can be open at once. The first answer starts open: in services.astro that is aria-expanded={i === 0 ? "true" : "false"} and hidden={i !== 0}.

Office open/closed status#

Each office card on /contact ([data-office="milwaukee"]) 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. The hours are OFFICE_HOURS in src/data/contact.ts:

ts
export const OFFICE_HOURS = {
  milwaukee: { tz: 'America/Chicago', days: [null, [7, 17.5], [7, 17.5], [7, 17.5], [7, 17.5], [7, 16], null] },
  madison: { tz: 'America/Chicago', days: [null, [8, 17], [8, 17], [8, 17], [8, 17], [8, 17], null] }
};

Day 0 is Sunday; null means closed; hours are decimals (17.5 is 5:30 pm). Keep them in step with the hours tables in OFFICES in the same file, which are what the cards show. Without JavaScript the badge reads "Office hours".

Count-up stats, scroll reveals and line drawings#

Elements with .reveal fade and rise into view, and .draw SVGs draw their lines, when they scroll into view (IntersectionObserver in main.ts). Anything above a revealed element is revealed too, so jumping with the End key or an anchor never leaves hidden content. [data-count] numbers count up once when they appear. All three are skipped when the visitor prefers reduced motion, and content is only hidden once the inline script in the layout's <head> has added the js class to <html>, so nothing disappears without JavaScript.

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. A back-to-top button (src/scripts/back-to-top.ts) appears after about one screen of scrolling and steps aside while the footer's "Back to top" link is visible. On the estimator it lifts above the sticky price bar, which has data-back-to-top-avoid. To remove the button, delete back-to-top.ts, its import in Layout.astro and the .back-to-top rules in global.css.

Forms#

There are two forms. Both validate in the browser and neither sends anything until you connect it. The success messages say so.

Form Where Validation
Quote request (data-quote-form) /estimate, step 5 Name, email, phone (10+ digits), site, ideal start, project notes (20+ characters), consent box
Contact (data-contact-form) /contact Name, email, optional phone (10+ digits if filled), topic, nearest office, message (10+ characters)

Validation is in wireForm() and check() in main.ts. It reads standard attributes (required, type="email", minlength, required checkboxes) and a data-validate attribute:

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, and fields are rechecked as they are corrected.

Connecting to Formspree#

wireForm(form, onValid) only calls onValid(form) once everything is valid; the comment "To send for real, POST new FormData(form) to your form service here" marks the spot. Create a form at Formspree, then give the contact form an action in contact.astro:

html
<form class="reveal mt-8 grid gap-5 sm:grid-cols-2" data-contact-form novalidate action="https://formspree.io/f/your-form-id" method="POST">

and send it in the "Contact form" block of main.ts before the form is reset:

ts
wireForm(form, async () => {
  try {
    const res = await fetch(form.action, { method: 'POST', body: new FormData(form), headers: { Accept: 'application/json' } });
    if (!res.ok) throw new Error('Send failed');
  } catch {
    alert('Sorry, your message could not be sent. Please call us.');
    return;
  }
  form.reset();
  form.classList.add('hidden');
  ok.classList.remove('hidden');
  ok.focus();
});

For the quote request, do the same inside wireForm(quoteForm, ...). The estimator choices live in a separate form, so append them to the data you send:

ts
const data = new FormData(form);
const r = estimate(state);
data.append('type', state.type);
data.append('size_ft2', String(state.size));
data.append('finish', state.finish);
data.append('timeline', state.timeline);
data.append('design', state.design ? 'yes' : 'no');
data.append('estimate', compact(r.low) + ' – ' + compact(r.high));
await fetch('https://formspree.io/f/your-form-id', { method: 'POST', body: data, headers: { Accept: 'application/json' } });

Then change the success text ("This is a demo, so nothing was sent...") in estimate.astro and contact.astro. The reference number (CDB-26-1234) is random; replace it with the ID your system returns, or remove it.

The site is static, so there is no server code. If you need one (to email a request or save it to a CRM), add an Astro adapter and an API endpoint, or use your host's functions (Netlify Functions, Vercel Functions, Cloudflare Workers) and post to that URL instead of Formspree.

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 main.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 default size is used); design must be a boolean. Anything else falls back to DEFAULT_ESTIMATE. Storage errors (private browsing, blocked storage) are ignored. A ?type= in the URL overrides the saved type.

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().

SEO and sharing#

Each page passes title and description to <Layout>; the layout writes the <title>, meta description, canonical URL, og:* tags and twitter:card, plus theme-color and the SVG favicon. Canonical and og:url use the clean URL (/services, not /services.html), and og:image is the absolute URL of /img/og-image.png. Both are built from site in astro.config.mjs, which reads SITE_URL:

sh
SITE_URL=https://www.your-domain.com pnpm build

or change the fallback (https://keystone.example.com) in astro.config.mjs. The 404 page passes noindex, which outputs <meta name="robots" content="noindex"> and no canonical.

For a sitemap, add the official @astrojs/sitemap integration, and add a public/robots.txt. For a real firm, LocalBusiness or GeneralContractor structured data (JSON-LD) with your address, phone and opening hours helps search engines show your details; add it as a <script type="application/ld+json"> in the layout's <head>.

Accessibility#

Built in:

  • Skip link, header/nav/main/footer landmarks, 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-controls and Esc to close.
  • Filter chips with aria-pressed, a live result count, accordion buttons with aria-expanded, estimator steps with aria-current="step" and a live estimate.
  • Native <dialog> lightbox with keyboard support and focus return.
  • Labelled form fields, inline errors linked with aria-describedby, and focus moved to the first error.
  • Meaningful alt text on drawings; decorative SVG is aria-hidden. The safety chart has a screen-reader table.
  • Reduced motion turns off reveals, line drawing, count-ups and smooth scrolling.
  • Text colours meet WCAG AA contrast.

When editing, keep one <h1> per page, give every new input a <label>, keep orange text on light backgrounds at signal-700, and keep the final value inside [data-count] spans.

Building and deploying#

sh
pnpm build        # static site in dist/
pnpm preview      # preview dist/ locally

dist/ is plain HTML, CSS and JavaScript. Pages are written as services.html rather than services/index.html (build.format: "file" in astro.config.mjs), and links point to /services without a trailing slash. Netlify, Cloudflare Pages and GitHub Pages serve /services from services.html automatically; Vercel does with cleanUrls (below). 404.html is served for unknown URLs by most hosts.

  • Netlify / Cloudflare Pages: build command pnpm build, publish directory dist.
  • Vercel: import the repository; it detects Astro and static output needs no adapter. Add a vercel.json with { "cleanUrls": true } so /services serves services.html.
  • GitHub Pages: use the official Astro GitHub Action. For a project site (username.github.io/repo), build with DEMO_BASE_PATH=/repo.
  • Any static host: upload the contents of dist/. If the host doesn't map /services to services.html, change build.format to "directory" in astro.config.mjs.

To serve the site from a sub-folder, set DEMO_BASE_PATH when building, for example DEMO_BASE_PATH=/keystone pnpm build. It becomes Astro's base, and every link and image written with url() includes it. astro.config.mjs also sets compressHTML: false, so the spaces between words and inline tags that start on a new line are kept.

demo/ (if present) is the build used for the freeframe.dev demo; you can delete it.

TypeScript 7 setup#

package.json installs two TypeScript packages on purpose. @typescript/native is TypeScript 7 and provides the tsc that pnpm typecheck runs. typescript is an alias for @typescript/typescript6, the TypeScript 6 API that astro check and typescript-eslint load. When those tools support TypeScript 7, replace both with a single typescript@7. tsconfig.json extends Astro's strict preset.

Troubleshooting#

Problem Fix
A link or image 404s after deploying to a sub-folder Write it with url("/path") rather than a hard-coded "/path", and build with DEMO_BASE_PATH set to the folder.
/services 404s but /services.html works Your host doesn't map clean URLs. On Vercel add { "cleanUrls": true }; elsewhere set build.format: "directory".
The estimator shows old answers They are saved in localStorage. Delete keystone-estimate-v1 or change ESTIMATE_KEY.
The office status says "Closed" during opening hours Check OFFICE_HOURS in src/data/contact.ts (day 0 is Sunday, decimal hours) and the tz value for your city.
The before/after images don't line up Use two images with exactly the same dimensions and framing.
Words run together after editing a page A line break before an inline tag is kept because compressHTML is false; if you turn compression on, keep inline tags on the same line as the text before them.
Content stays invisible after editing Elements with .reveal are hidden until scrolled into view. Check the browser console for errors in main.ts.
Social previews show no image Build with SITE_URL set to your live domain so og:image is absolute and correct.

License#

MIT. Free for personal and commercial use, including client projects. The license text is in LICENSE.txt in the download. All artwork (SVG drawings, portraits, icons, favicon and social image) is included under the same license. Corbel Design-Build and every person, project and address in the template are fictional.

Search documentation

Search the guides and every template's documentation