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).
pnpm install
pnpm dev # http://localhost:4321With 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.mdCustomising#
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:
@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:
--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:
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:
{ 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." },slugpicks the drawingpublic/img/project-<slug>.svg;altis optional and defaults to "Elevation drawing of <name>".typeis a key ofPROJECT_TYPES(commercial,residential,renovation,architecture). The filter chips and their counts are built fromPROJECT_TYPESandPROJECTS; add a key there to add a filter.areais the floor area in m²; the card also shows it in ft² (areaFt2()).- The search text (
data-text) is built bysearchText()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]):
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:
{ 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.
Navigation, header and footer#
The header and footer are components, so there is one place to change them:
- Links:
src/data/nav.ts.mainNavis the desktop menu,mobileNavthe mobile menu (Home, the main links and "Get an estimate"), andfooterNavthe footer's Work and Company columns. Write paths without.html("/about#careers"); the components pass them throughurl(). - Current page: each page passes
currentto<Layout>(current="services"), which setsaria-current="page"on the matching links; the underline and colour come from thearia-[current=page]styles on.nav-linkand.mobile-link. The case study passessection="projects"instead, which setsaria-current="true"because it is inside that section. The "Get an estimate" button in the header is shown from thesmbreakpoint 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 anaria-hiddeninline SVG inSiteFooter.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 issrc/lib/estimate.ts, and the UI is the "Estimator: 4 steps + request form" block inmain.ts. -
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: { ..., 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'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 (rendered byOptionCard.astrofromTYPES,FINISHandTIMELINE), 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 themetatext 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=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 puts focus on the new step's heading. Without JavaScript all steps are shown at once.
Projects filter and search#
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.
Gallery lightbox#
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:
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:
<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:
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:
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:
SITE_URL=https://www.your-domain.com pnpm buildor 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/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. - Labelled 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 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#
pnpm build # static site in dist/
pnpm preview # preview dist/ locallydist/ 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 directorydist. - Vercel: import the repository; it detects Astro and static output needs no adapter. Add a
vercel.jsonwith{ "cleanUrls": true }so/servicesservesservices.html. - GitHub Pages: use the official Astro GitHub Action. For a
project site (
username.github.io/repo), build withDEMO_BASE_PATH=/repo. - Any static host: upload the contents of
dist/. If the host doesn't map/servicestoservices.html, changebuild.formatto"directory"inastro.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.