Overview#
Slatework is a multi-page marketing website for a recruiting software company: an applicant tracking system (ATS) for companies with 50 to 1,000 employees. The fictional Slatework, Inc. is based in Lexington, Kentucky, and works in Eastern time. This is the Astro version: Astro 7 with TypeScript and Tailwind CSS v4, no UI framework, and a static build that runs on any host. It matches the HTML version page for page. This guide covers version 1.0.0. The template page, with a link to the live demo, is at freeframe.dev/templates/slatework.
The look is corporate and structured: white paper with hairline grids, a deep indigo brand color, a marigold highlight, Hubot Sans headings and Fira Sans body text, with a full dark theme.
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/slatework into demo/ (only used for the demo on freeframe.dev) |
Google Fonts load from the internet, so the fonts need a connection.
Pages#
| File | Route | Purpose and notable features |
|---|---|---|
src/pages/index.astro |
/ |
Home: live pipeline board with funnel and projected time to hire, customer logos, six feature pillars, interactive scorecard, results, testimonials, call to action |
src/pages/product.astro |
/product |
Product tour: job board picker and posting preview, stage rules, weighted scorecard, panel scheduler, offer pay band check, SVG report charts |
src/pages/pricing.astro |
/pricing |
Monthly / yearly switch, three plans, cost and cost-per-hire calculator, comparison table, billing FAQ |
src/pages/customers.astro |
/customers |
Industry and company size filters, eight stories with logo marks, before-and-after charts and quotes |
src/pages/integrations.astro |
/integrations |
Search, category filters with counts, 22 integrations with drawn marks, detail dialog, API example |
src/pages/security.astro |
/security |
Controls, data retention example, voluntary self-ID reporting, subprocessors table |
src/pages/resources.astro |
/resources |
Most-read list, rolling monthly webinars with registration, six dated articles with a topic filter |
src/pages/demo.astro |
/demo |
Validated demo request form, plan preselected from pricing, success panel with a reference and proposed meeting time |
src/pages/404.astro |
404.html |
Links to the main pages and a drawn board illustration |
Project structure#
slatework/
astro.config.mjs site URL, base path, file-style output, Tailwind plugin
package.json scripts and dependencies
tsconfig.json Astro's strict TypeScript preset
eslint.config.mjs ESLint flat config (typescript-eslint, eslint-plugin-astro)
public/img/ favicon.svg, og-image.svg (source) and og-image.png (social image)
src/
pages/*.astro one file per page (see Pages)
layouts/Layout.astro <html>, <head> metadata, theme script, header, <main>, footer, script import
components/
SiteHeader.astro logo, main menu, theme button, "Book a demo", mobile menu
SiteFooter.astro footer columns, contact details, copyright year
Logo.astro the Slatework mark
ArrowIcon.astro the arrow on "Book a demo" buttons
data/ content and settings, typed (see Content)
lib/site.ts site name, theme colors and the url() helper
scripts/main.ts every interaction, in numbered sections
styles/global.css Tailwind source: color tokens, theme, small component layer
README.md short introduction
DOCS.md this guide
CHANGELOG.md version historyCustomizing#
Brand and colors#
Colors are CSS variables at the top of src/styles/global.css: :root holds the light theme and .dark the dark
theme. @theme inline maps them to Tailwind names, so bg-brand, text-ink-soft or border-line follow the
theme.
| Token | Light | Used for |
|---|---|---|
--paper |
#ffffff |
Page and card background |
--canvas |
#f4f4f1 |
Alternate bands, board columns |
--line / --line-strong |
#dddfd7 / #878b7f |
Hairlines / inputs and chart axes |
--ink, --ink-soft, --ink-faint |
#15171e, #454955, #595d69 |
Text |
--brand / --brand-strong |
#3a3fd0 / #2b2fa8 |
Buttons, links, focus |
--applied, --screen, --interview, --offer, --hired |
stage colors | Board dots, chips, funnel bars |
Fixed colors that don't change with the theme (the night band, marigold) are in the @theme block.
To change the brand color, edit both themes; the dev server picks it up on save:
:root { --brand: #0f6b5c; --brand-strong: #0b5649; --brand-soft: #e3f3ef; --brand-line: #b9e0d6; }
.dark { --brand: #7fd6c3; --brand-strong: #a6e5d7; --brand-soft: #12302a; --brand-line: #24584d; }The logo mark (src/components/Logo.astro), public/img/favicon.svg and public/img/og-image.svg use #3a3fd0
directly; change them too. The browser theme color is site.themeColor in src/lib/site.ts.
Typography#
Headings use Hubot Sans (variable width, set wider with font-stretch: 112%) and body text Fira Sans.
Both load from Google Fonts with a <link> in src/layouts/Layout.astro:
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fira+Sans:ital,wght@0,400;0,500;0,600;1,400&family=Hubot+Sans:wdth,wght@100..125,500..800&display=swap" />The families are set as --font-display and --font-sans in src/styles/global.css. To swap fonts, change the
link and those two variables. Hubot Sans draws its zero with a bar; that is the font's design.
Content#
Page text is in the .astro pages. Everything interactive reads typed data from src/data/:
| File | Exports |
|---|---|
company.ts |
COMPANY: name, phone, email, home time zone (America/New_York) |
pipeline.ts |
STAGES (the five stages in order), REQ (the requisition, openings: 2), STAGE_BASE (candidates per stage not shown as cards: applied: 38, screen: 9, interview: 3, offer: 0, hired: 0), CANDIDATES (board cards: id, name, source, age in days, starting stage, avatar hue, optional score), STAGE_REMAINING and TIME_TO_HIRE_TARGET |
scorecard.ts |
COMPETENCIES (name, weight, prompt, default rating), RATING_LABELS, RECOMMENDATIONS |
product.ts |
BOARDS, POSTING_LOCATIONS, WORK_HOURS, INTERVIEWERS, SCHEDULE_DAYS, SCHEDULE_STEP, SCHEDULE_PROPOSALS, DISPLAY_ZONES, OFFER_BAND |
pricing.ts |
PLANS, ANNUAL_DISCOUNT, MIN_EMPLOYEES, MAX_EMPLOYEES, ENTERPRISE_HINT, DEFAULT_PRICING |
customers.ts |
INDUSTRIES, SIZES, CASES (also used for the home page logo row and testimonials) |
integrations.ts |
INT_CATEGORIES, INTEGRATIONS |
security.ts |
RETENTION, DEFAULT_RETENTION |
resources.ts |
TOPICS, POSTS (fixed past dates), WEBINAR_DATES, WEBINAR_TIME, WEBINAR_COUNT, WEBINAR_MIN_DAYS, WEBINAR_TOPICS |
demo.ts |
PERSONAL_DOMAINS, COMPANY_SIZES, DEMO_LEAD_DAYS, DEMO_TIMES |
nav.ts |
MAIN_NAV (header and mobile menu) and FOOTER_NAV |
Example: add an integration to INTEGRATIONS in src/data/integrations.ts. Give it a unique id, an existing
category, a tile color and a small mark drawn in white inside a 40×40 box:
{ id: 'shiftnote', name: 'Shiftnote', category: 'messaging', bg: '#0f766e', setup: '2 minutes', direction: 'One-way',
blurb: 'Post interview reminders to the hiring team channel.',
syncs: ['Interview reminders', 'Scorecard due alerts'],
mark: '<circle cx="20" cy="20" r="9" fill="#fff"/>' },Category counts, search and the detail dialog pick it up automatically. Update the count in the page's
description and the static "22 integrations" line in src/pages/integrations.astro if you want them exact before
the script runs.
Example: add a customer story to CASES with industry set to a key of INDUSTRIES and size to s, m or l.
metrics[0] draws the before-and-after chart. The home page logo row lists every story, and its three
testimonials are picked by id in src/pages/index.astro (TESTIMONIALS).
Example: add a candidate to the board by appending to CANDIDATES; the column counts, funnel and time to hire
follow, both in the page as built and when the script redraws it.
Images and artwork#
All artwork is inline SVG drawn for this template: feature pillar icons, report charts, the security and 404
illustrations, and the integration and customer marks (in src/data/, as mark strings). There are no photos.
If you add photos, keep the existing aspect ratios: avatars are square, case study marks 40×40.
The social image is public/img/og-image.png (1200×630), rendered from og-image.svg. After editing the SVG,
regenerate the PNG with any SVG renderer. The favicon is public/img/favicon.svg.
Navigation, header and footer#
The header is src/components/SiteHeader.astro and the footer src/components/SiteFooter.astro; both read their
links from src/data/nav.ts. To add a page, create src/pages/<name>.astro with <Layout> and its title,
description and page, add it to MAIN_NAV (and a FOOTER_NAV group if you like) and pass the same key as
current from the page so its menu links get aria-current="page". Link with url("/<name>") from
src/lib/site.ts so links keep working under a base path.
Dark mode#
The theme button in the header toggles the dark class on <html> and saves the choice as slatework:theme. An
inline script in src/layouts/Layout.astro applies the saved choice, or the visitor's system setting, before the
page paints. To make light the default regardless of the system setting, remove the matchMedia(...) check from
that script. To remove dark mode, delete the button in SiteHeader.astro and the theme part of the script; the
.dark variables can stay.
Features#
All interactions are in src/scripts/main.ts, imported once by the layout. Each feature looks for its markup
([data-pipeline], [data-pricing]...) and does nothing on pages without it.
Hero pipeline board#
src/pages/index.astro, section 5 (initPipeline). The board shows the requisition REQ with five columns
from STAGES. Each column's count is STAGE_BASE[stage] plus the cards in it.
- Drag and drop: with a mouse or pen, drag a card anywhere; on touch screens, drag by the card's initials
(
data-grip), so swiping the board still scrolls it. The board scrolls sideways when you drag near its edge. Escape cancels a drag. - Keyboard and screen readers: each card has a "Move" button (
data-move) that opens a menu of stages (role="menu"; arrow keys, Home, End, Escape, Tab). Choosing a stage moves the card, returns focus to it and announces the new count and time to hire. - Funnel: "reached" counts each stage plus every later stage. With the starting data: Applied 61, Screen 19, Interview 7, Offer 2, Hired 1, so Applied to Hired is 1.6%.
- Projected time to hire: the average of
age + STAGE_REMAINING[stage]over cards in Offer and Hired, rounded. Start: Keziah W. (27 + 0) and Esmeralda P. (19 + 4) give 25 days, "5 days under the 30-day target". Moving Ramona D. (age 16) to Offer makes Offer 2 and time to hire 23 days. Then dragging Esmeralda P. to Hired makes Hired 2, "2 of 2 openings filled", time to hire 22 days and a 3.3% conversion. - Moves are saved (
slatework:pipeline); "Reset board" restores the starting data. Moved cards go to the top of their new column. The starting counts and funnel are also written into the page at build time, from the same data, so it reads right before the script runs.
Interview scorecard#
index.astro and product.astro, initScorecards. Every [data-scorecard] panel lists COMPETENCIES with 1–4
rating buttons (role="radio", arrow keys change the rating). The weighted score is
sum(weight × rating) / 100. The defaults (4, 3, 3, 2, 4 with weights 30, 25, 20, 15, 10) give 3.25, Hire.
Rating Product knowledge 4 gives 3.55, Strong hire; then Customer empathy 1 gives 2.65, No hire.
Thresholds in RECOMMENDATIONS: 3.5 Strong hire, 2.75 Hire, 2.0 No hire, below that Strong no hire. The home page
version (data-compact) hides the interview prompts.
Job boards and posting preview#
product.astro#sourcing, initBoards. Checking boards updates the board count, the 30-day cost and typical
applicants. Default: careers page, Hirewire and Openroles, 3 boards, $0, 60 applicants; adding Talentcast makes
$179, 76. The posting preview follows the title, location and pay range fields, and flags a maximum below the
minimum.
Panel scheduler#
product.astro#scheduling, initScheduler. For each of the next SCHEDULE_DAYS (5) weekdays after today in
Eastern time, it intersects every chosen interviewer's working hours (WORK_HOURS, 9:00 a.m. to 5:00 p.m. in
their own zone) minus their busy blocks, then proposes up to SCHEDULE_PROPOSALS (3) non-overlapping slots on
30-minute steps, at most two per day. Busy blocks are tied to business day numbers (0 = the first weekday after
today), so the demo never runs out of dates. Times are shown in the visitor's zone by default; "Show times in"
switches between US zones, and each slot lists every interviewer's local time.
With the default panel (Estelle, Renzo, Vittorio), 60 minutes, on Monday, November 9, 2026, the slots in Eastern time are Tue, Nov 10, 12:30–1:30 PM; Wed, Nov 11, 3:00–4:00 PM; and Wed, Nov 11, 4:00–5:00 PM. Adding Ursula gives Wed 3:00 PM, Wed 4:00 PM and Thu, Nov 12, 1:00 PM.
Offer pay band check#
product.astro#offers, initOffer. The slider runs from OFFER_BAND.sliderMin to sliderMax. Inside the band
($68,000–$84,000) only the hiring manager approves ($76,000: "Inside band", 1 approver); below it the people team
is added ($64,000: "Below band", 2); above it the VP and Finance are added ($88,000: "Above band", 3), from
OFFER_BAND.rules. The slider's aria-valuetext reads the amount and the band status.
Pricing and calculator#
pricing.astro, initPricing. Monthly prices per employee are in PLANS (Essentials $5, Growth $8); yearly
billing takes ANNUAL_DISCOUNT (20%) off: $4 and $6.40. Companies under MIN_EMPLOYEES (50) are billed as 50.
The calculator shows the monthly cost, the yearly cost and the cost per hire.
| Inputs | Result |
|---|---|
| 200 employees, Growth, yearly, 40 hires (default) | $1,280 a month, $15,360 a year, $384 per hire, saves $3,840 |
| Same, monthly | $1,600 a month, $19,200 a year, $480 per hire |
| 30 employees, Growth, monthly | $400 a month (billed as 50) |
| 800 employees, Essentials, yearly, 100 hires | $3,200 a month, $38,400 a year, $384 per hire, Enterprise hint shown |
The choice is saved (slatework:pricing). URL parameters override it: ?billing=monthly|annual,
?plan=essentials|growth, ?employees=1–1000, ?hires=1–2000. The "Book a demo with this estimate" link
passes plan and employees to /demo (built with url(), so it keeps the base path).
Customer stories#
customers.astro, initCustomers. Industry buttons and a company size select filter CASES; the URL keeps them
as ?industry= (a key of INDUSTRIES) and ?size=s|m|l. Financial services shows 2 of 8; with 201–500
employees, 1 (Pellworth); 501–1,000 employees alone shows 4.
Integrations directory#
integrations.astro, initIntegrations. Search matches name, description and category; category buttons show
counts (HRIS and payroll 4, Calendars and video 4, Job boards 6, Background checks 2, Assessments 2,
E-signature 2, Messaging 2). The URL keeps ?category= and ?q=; ?category=boards shows "6 integrations in Job
boards" and searching "payroll" finds 4. Each card opens a native <dialog> with what syncs; Escape or Close
returns focus to the card.
Data retention example#
security.astro#retention, initRetention. Choosing 12, 24 or 36 months (RETENTION) shows the date a
candidate rejected today would be anonymized, and the reminder date a month earlier, worked out from today in
Eastern time. On Monday, November 9, 2026, 24 months gives Thursday, November 9, 2028 (reminder Oct 9, 2028) and 36
months Friday, November 9, 2029.
Webinars and articles#
resources.astro, initResources. Webinars follow a rule: the second Thursday of each month at 1:00 p.m. Eastern
(WEBINAR_TIME), the next WEBINAR_COUNT (3) at least WEBINAR_MIN_DAYS (3) days away. On November 9, 2026 that
is Nov 12, Dec 10 and Jan 14. The topic cycles through WEBINAR_TOPICS by month. Times also show in the visitor's
zone. Articles in POSTS keep their real dates and can be filtered by topic (?topic=); each expands in place.
Menu, header and motion#
The mobile menu button toggles #mobile-menu (aria-expanded, Escape closes). The header gets a shadow once the
page scrolls. Sections with reveal fade in once as they scroll into view; with reduced motion they show
immediately, and drag animations are turned off.
Keeping the demo current#
Nothing dated goes stale. All "upcoming" dates are computed in the browser from today in Eastern time
(COMPANY.timeZone):
| Content | Rule | Switch |
|---|---|---|
| Interview slots | Next 5 weekdays after today | Busy blocks use business day numbers |
| Webinars | Second Thursday of the month, at least 3 days away | WEBINAR_DATES = null; set an array of 'YYYY-MM-DD' strings for fixed dates |
| Proposed demo time | First weekday at least DEMO_LEAD_DAYS (1) after today, at 10:00 a.m. or 2:00 p.m. Eastern |
DEMO_TIMES |
| Retention example | Today plus 12, 24 or 36 months | |
| Copyright year | Current year (the build year in the page, updated by the script) | [data-year] |
Articles keep their fixed past dates. For reviews and screenshots, ?now=YYYY-MM-DDTHH:MM (read as Eastern
time) pretends it is another moment; turn it off with ALLOW_NOW_PARAM = false near the top of
src/scripts/main.ts.
Forms#
All forms validate in the browser with inline messages linked by aria-describedby, focus the first problem and
then show a success panel. No form sends anything until you connect it.
| Form | Page | Checks | Success |
|---|---|---|---|
Demo request (data-demo-form) |
demo.astro |
Names, work email (format, and not a personal domain from PERSONAL_DOMAINS), company, role, size |
"Thanks, {name}. Your demo request is in.", reference SW-XXXXXX, proposed time in the visitor's zone and in Eastern, next steps |
Webinar registration (data-webinar-form) |
resources.astro |
Name, email | Registration reference WB-XXXXX and the session time |
/demo also reads ?plan=essentials|growth|enterprise&employees= to preselect the plan and company size, and
?topic=security to tick "Security and reporting".
To connect a form service such as Formspree, add the request inside setupForm in src/scripts/main.ts (section
"Form helper"), where it calls onSuccess, and show the success panel only when the service answers OK:
const res = await fetch("https://formspree.io/f/your-id", {
method: "POST",
body: new FormData(form),
headers: { Accept: "application/json" },
});
if (res.ok) { /* run the existing success code */ } else { /* show an error */ }Make the submit listener async when you do. The site is static, so the form needs an outside service (or your
own API) to receive the data.
Saved data (localStorage)#
All storage goes through safeStorage in src/scripts/main.ts, which falls back to memory when the browser
blocks localStorage, so the page keeps working for the visit.
| Key | Shape | Validation |
|---|---|---|
slatework:theme |
"light" or "dark" |
Anything else is ignored |
slatework:pipeline |
{ "v": 1, "stages": { "ramona": "offer", ... }, "order": ["ramona", ...] } |
Unknown candidates or stages are dropped; missing cards keep their starting stage |
slatework:pricing |
{ "billing": "annual", "employees": 200, "plan": "growth", "hires": 40 } |
Each field checked against its allowed values and range |
Clear them with "Reset board", or from the browser's developer tools (Application → Local Storage).
SEO and sharing#
Each page passes its title and description to <Layout> (the home page also an ogTitle). The layout writes
the description, a canonical URL, Open Graph tags and twitter:card. Canonical, og:url and og:image are
absolute URLs built from site in astro.config.mjs, which reads SITE_URL:
SITE_URL=https://www.example.com pnpm buildThe 404 page passes noindex. Add a sitemap with @astrojs/sitemap and a public/robots.txt if your host doesn't
create them.
Accessibility#
- Skip link, landmarks, one
<h1>per page andaria-current="page"in the menus. - Visible focus outlines, AA contrast in both themes, and reduced motion respected.
- The pipeline board has a full keyboard path (Move menu) and announces changes in a live region (
#live-region). - Scorecard ratings are radio groups; filters use
aria-pressed; the integration dialog is a native<dialog>. - Keep one
h1per page, label new inputs, and keep the Move menu if you change the board.
Building and deploying#
pnpm build # static site in dist/
pnpm preview # preview dist/ locallydist/ is plain HTML, CSS and JavaScript. Tailwind is compiled by @tailwindcss/vite during the build, so there
is no separate CSS step; it scans src/ for class names, so classes built from data must be written out in full
(as the funnel bar colors in index.astro are). Pages are written as pricing.html rather than
pricing/index.html (build.format: "file" in astro.config.mjs), and links point to /pricing without a
trailing slash. Netlify, Cloudflare Pages and GitHub Pages serve /pricing from pricing.html automatically;
Vercel does with cleanUrls (below).
- 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/pricingservespricing.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/pricingtopricing.html, changebuild.formatto"directory"inastro.config.mjs.
To serve the site from a sub-folder, set DEMO_BASE_PATH when building, e.g.
DEMO_BASE_PATH=/slatework pnpm build. It becomes Astro's base, and every link written with url() (including
the calculator's demo link that the script writes) includes it.
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#
- A new class has no effect: Tailwind only sees whole class names in
src/. Write"bg-hired", not`bg-${tone}`. - Links 404 on the host: the host doesn't map
/pricingtopricing.html. UsecleanUrlson Vercel, or switchbuild.formatto"directory". - The board keeps an old arrangement: it's saved in
slatework:pipeline; click "Reset board". - Scheduler shows no slots: the panel has no shared time in the next five weekdays. Check
busyblocks andWORK_HOURSinsrc/data/product.ts, or pick a shorter interview. - Dates look a day off: dates are computed in Eastern time. Change
COMPANY.timeZoneinsrc/data/company.tsfor another home zone. - Fonts look different offline: Hubot Sans and Fira Sans come from Google Fonts and need a connection.
- Saved choices don't persist in a preview frame: some sandboxed frames block storage; the site then keeps state in memory for the visit only.
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. Hubot Sans and
Fira Sans are provided by Google Fonts under the SIL Open Font License. Slatework, its customers,
integrations, interviewers and people are fictional.