Overview#
Wardline is a corporate B2B SaaS website template for a compliance automation product: a platform that helps
startups and mid-size companies prepare for SOC 2, ISO 27001, HIPAA, GDPR, PCI DSS and NIST CSF with continuous
control monitoring, evidence collection, policies, vendor risk, access reviews and a public trust center. The
fictional company, Wardline, Inc., is based in Richmond, Virginia, and works in Eastern time
(America/New_York). This is the Nuxt version: Nuxt 4, Vue 3, TypeScript and Tailwind CSS v4, version 1.0.0,
generated as a static site. See the template page at https://freeframe.dev/templates/wardline, which links to the
live demo.
The copy is careful about what a compliance tool can claim: Wardline helps customers prepare, and independent auditors (a licensed CPA firm for SOC 2, an accredited certification body for ISO 27001) issue reports and certificates. Keep that distinction if you adapt the template for a real product.
Quick start#
Requirements: Node.js 24 or newer and pnpm (npm works too).
pnpm install
pnpm dev # http://localhost:3000With npm: npm install, then npm run dev. The fonts are self-hosted, so the site looks right offline too. Add ?now=2027-11-24T12:00 to any page's URL to preview it at an Eastern date and time
(see Keeping the demo current).
| Command | What it does |
|---|---|
pnpm dev |
Development server at http://localhost:3000 with hot reload |
pnpm generate |
Static site in .output/public/ |
pnpm preview |
Serve the last build locally |
pnpm build |
A Node.js server build in .output/ (pages are still prerendered) |
pnpm typecheck |
nuxt prepare, then vue-tsc with Nuxt's strict settings |
pnpm lint |
ESLint 10 with @nuxt/eslint |
pnpm build:demo |
Build for /demos/nuxt/wardline into demo/ (only used for the demo on freeframe.dev) |
Pages#
| Route (file) | Purpose | Notable sections and features |
|---|---|---|
/ (app/pages/index.vue) |
Home | Hero with a control dashboard that switches between SOC 2, ISO 27001 and HIPAA, drawn customer marks, connect/monitor/fix/audit steps with isometric art, framework cards built from data, numbers band, "who does what" comparison, Kelvara story teaser, assessment CTA |
/platform (platform.vue) |
Platform tour | Six keyboard tabs (controls, evidence, policies, vendor risk, access reviews, trust center), each with its own HTML/SVG mockup; roles; access table |
/frameworks (frameworks.vue) |
Framework explorer | Framework chips, control families as bars, cross-mapping with a Venn diagram and a control table, a pairwise overlap matrix, "who issues what" table |
/assessment (assessment.vue) |
Readiness assessment | Eight-question quiz, score ring, ranked gaps, preparation range, deadline verdict, method notes |
/pricing (pricing.vue) |
Pricing | Three plan cards, calculator with an itemized estimate, comparison table, FAQ |
/integrations (integrations.vue) |
Integrations directory | Search, category chips with counts, sorting, 24 cards with drawn monograms, empty state |
/trust (trust.vue) |
Trust center | Control status by category, rolling report dates, documents, subprocessors, document request form with NDA |
/customers (customers.vue) |
Customer stories | Framework filter, three stories with generated before-and-after charts |
/contact (contact.vue) |
Book a demo | Three-step form with validation, saved draft, slot picker in the visitor's time zone, success state |
app/error.vue (404.html) |
Not found | "Control not found" illustration and links |
Project structure#
wardline/
├─ app/
│ ├─ app.vue <NuxtLayout><NuxtPage /></NuxtLayout>, pages keyed by address (see Links to the same page)
│ ├─ error.vue the 404 page (generated as 404.html)
│ ├─ router.options.ts writes query strings with encodeURIComponent, like the HTML version
│ ├─ layouts/default.vue skip link, header, page, footer and the #notice bar
│ ├─ pages/ one .vue file per page (see above)
│ ├─ components/
│ │ ├─ SiteHeader.vue status strip, logo, main menu, "Book a demo" button, mobile menu
│ │ ├─ SiteFooter.vue footer columns, Richmond office, office status and copyright year
│ │ ├─ HeroDashboard.vue the home page's switchable control dashboard
│ │ ├─ FrameworkExplorer.vue framework chips, families, Venn diagram and control table
│ │ ├─ ReadinessAssessment.vue the eight-question quiz and its results
│ │ ├─ TrustRequest.vue the trust center's document request form
│ │ ├─ DemoBooking.vue the three-step demo booking form and slot picker
│ │ ├─ WardSeal.vue, IntegrationMonogram.vue, StoryChart.vue drawn SVG art from data
│ │ ├─ DayOffset.vue, TodayDate.vue, ReportDate.vue dates worked out from today
│ │ └─ ArrowIcon.vue the arrow in buttons
│ ├─ composables/
│ │ ├─ useNow.ts today's date and time in Richmond (with the ?now= preview), after mount
│ │ ├─ usePageSeo.ts title, description, canonical and Open Graph tags per page
│ │ └─ useCurrentPage.ts aria-current for header and menu links
│ ├─ data/
│ │ ├─ site.ts all content data: frameworks, controls, integrations, pricing, quiz, trust center, demo, stories
│ │ └─ nav.ts header and mobile menu items
│ ├─ utils/
│ │ ├─ dates.ts Eastern time, federal holidays, business days, report dates, office status, KEEP_DEMO_CURRENT
│ │ ├─ util.ts own(), plural(), money(), code() and the control library math (controlsFor, overlap)
│ │ ├─ assessment.ts, pricing.ts, demo.ts the rules behind the quiz, the price quote and the booking form
│ │ ├─ art.ts framework seals and integration monograms as SVG markup
│ │ ├─ safeStorage.ts localStorage with an in-memory fallback, readJSON/writeJSON and STORAGE_KEYS
│ │ ├─ announce.ts messages for the #notice bar
│ │ ├─ stats.ts, url.ts counts from the data; replaceUrl()
│ ├─ plugins/
│ │ ├─ reveal.client.ts scroll reveals
│ │ ├─ same-page.client.ts links to the page already showing start it afresh
│ │ ├─ keep-hash.client.ts keeps the #hash as opened (platform tabs)
│ │ └─ stable-build.server.ts reproducible builds
│ └─ assets/
│ ├─ css/main.css Tailwind theme (@theme tokens), @font-face rules, base styles and component classes
│ └─ fonts/ Anek Latin, Source Sans 3 and Roboto Mono (woff2) with their OFL texts
├─ public/img/
│ ├─ favicon.svg the logo mark
│ └─ og-image.svg, og-image.png social image source and the PNG used in og:image (1200×630)
├─ nuxt.config.ts base path, head defaults, SITE_URL, prerendered routes, Tailwind
├─ eslint.config.mjs, tsconfig.json
├─ package.json, pnpm-workspace.yaml, pnpm-lock.yaml
└─ README.md, DOCS.md, CHANGELOG.mdEvery page is prerendered to HTML by pnpm generate with its content from app/data/site.ts, and Vue takes it
over in the browser. Anything that depends on the clock, the address or saved data (dates, the office status,
saved answers, ?fw= and other parameters) is filled in after the page has loaded, so the generated HTML shows
the dates as written (Nov 3, 2026, © 2026). Each page calls usePageSeo() with its page name, which becomes
<body data-page="…">.
Customizing#
Brand and colors#
Colors and fonts are Tailwind theme tokens in the @theme block of app/assets/css/main.css, so every utility
(bg-ink, text-teal, border-line) follows them.
| Token | Value | Used for |
|---|---|---|
--color-ink |
#0b1a2c |
Headings, body text, dark bands and footer |
--color-paper / --color-card / --color-cloud |
#f6f8fa / #ffffff / #edf1f5 |
Page background, panels, alternate bands |
--color-line / --color-line-strong |
#dbe2ea / #93a1b3 |
Hairlines and input borders |
--color-slate / --color-slate-2 |
#3f4d61 / #566376 |
Secondary text and captions (AA on light backgrounds) |
--color-frost |
#a9b8ca |
Secondary text on ink |
--color-teal, -deep, -soft, -bright |
#0b6f80 … |
The accent: buttons, links, focus rings, charts |
--color-pass, --color-attn, --color-fail (+ -soft) |
green, amber, red | Status pills, always with a symbol and a word |
To change the accent, edit the teal tokens; pnpm dev picks the change up instantly:
--color-teal: #1d4ed8;
--color-teal-deep: #1e3a8a;
--color-teal-soft: #dbeafe;Some SVG art (the seals and monograms in app/utils/art.ts, the Venn diagram in FrameworkExplorer.vue, the
charts in StoryChart.vue) and the inline SVGs in the pages use hex values directly; search for #0b6f80 to
update them too. <meta name="theme-color"> is set in app.head in nuxt.config.ts.
Typography#
Three families are self-hosted from app/assets/fonts/ (the same Latin files Google Fonts serves the HTML
version) and declared with @font-face at the top of app/assets/css/main.css:
- Anek Latin (500–700) for headings (
--font-display) - Source Sans 3 (400, 600, 700, italic 400) for body text (
--font-sans) - Roboto Mono (400, 500) for control IDs, labels and figures (
--font-mono)
To swap a family, put its woff2 files in app/assets/fonts/, change the @font-face rules and the matching
variable in @theme. In SVG, quote
"Source Sans 3" (font-family="'Source Sans 3'"), because a family name ending in a number must be quoted.
Content#
Everything data-driven lives in app/data/site.ts, with a TypeScript type for each shape:
| Export | What it holds |
|---|---|
FRAMEWORKS |
Six frameworks with a one-letter code, seal shape, issuer, summary and prep |
FAMILIES |
Fifteen control families by two-letter prefix |
CONTROLS |
The 81-control library as [id, title, frameworkLetters] |
INTEGRATION_CATEGORIES, INTEGRATIONS |
Six categories and 24 integrations with checks, setup minutes and access |
ANNUAL_DISCOUNT, PLANS, ADDONS |
Pricing rules |
ASSESSMENT |
Sizes, five scored questions, targets, deadlines and score bands |
TRUST_CATEGORIES, SUBPROCESSORS, DOCUMENTS, PERSONAL_DOMAINS |
Trust center content and email checks |
DEMO, SPECIALISTS |
Demo form options, slot times and routing |
STORIES |
Customer stories and their before-and-after metrics |
Adding a control mapped to SOC 2, ISO 27001 and NIST CSF updates every count, bar, Venn diagram and matrix cell:
["AC-10", "Break-glass accounts sealed and alerted", "SIN"],Adding an integration:
{ id: "shiftledger", name: "Shiftledger", category: "hr", checks: 7, summary: "…", access: "Read-only report token", minutes: 5 },Page copy (headings, story text, FAQ) is markup in each app/pages/*.vue file.
Images and artwork#
All art is SVG drawn for this template: the logo, the four step illustrations, the six platform mockups (HTML
with inline SVG), the integrations hub, the 404 shield and the customer marks are inline in the pages. Framework
seals (sealArt(), drawn by <WardSeal>) and integration monograms (monogramArt(), drawn by
<IntegrationMonogram>) are built in app/utils/art.ts; the Venn diagram (FrameworkExplorer.vue) and the
story charts (StoryChart.vue) are Vue templates. The seals are original generic badges, not any standards body's mark; keep it that way
unless you have permission to use an official logo.
The social image is public/img/og-image.svg; after editing it, render the PNG (1200×630) with a headless
browser or any SVG-to-PNG tool, with the template's fonts available, and keep the PNG at
public/img/og-image.png. The favicon is public/img/favicon.svg.
Navigation, header and footer#
The header lives in app/components/SiteHeader.vue and the footer in app/components/SiteFooter.vue; every page
gets them from app/layouts/default.vue. Menu items are in app/data/nav.ts:
export const NAV = [
{ label: "Platform", href: "/platform" },
// …
];NAV fills the desktop menu and MOBILE_NAV (the same plus the readiness assessment) the phone menu. The link to
the page you are on gets aria-current="page" (from useCurrentPage()); on /contact the header's "Book a demo"
button is marked. The footer columns are written out in SiteFooter.vue.
To add a page, create app/pages/<name>.vue with a <main id="main" class="flex-1"> and a usePageSeo({ title, description, page: "<name>" }) call, add its route to routes in nuxt.config.ts (so it is generated) and add
it to app/data/nav.ts. Write internal links as <NuxtLink to="/pricing">, without .html, so they keep working
under a base path.
Features#
Hero control dashboard#
HeroDashboard.vue switches the dashboard on the home page between three frameworks with the
[data-dash-fw] buttons (aria-pressed). Each framework's passing, attention and failing counts, readiness
percentage and audit-window offset are in its STATES object. The audit window date is
today plus the offset (46 days for SOC 2, 73 for ISO 27001, 29 for HIPAA), so it never goes stale: on Nov 3,
2026 the SOC 2 window reads "Dec 19, 2026 · in 46 days".
Platform tabs#
The tour in app/pages/platform.vue follows the ARIA tabs pattern (TABS and select() in the page): arrow
keys, Home and End move between tabs, and the URL hash (#controls, #evidence, #policies, #vendors,
#access, #trust) opens a tab directly and scrolls the tour into view. Choosing a tab writes its hash back
with history.replaceState. Dates inside the mockups use <DayOffset> (see Keeping the demo current).
Framework explorer and cross-mapping#
FrameworkExplorer.vue on /frameworks renders the selected framework's issuer, preparation note, control count and
families from FRAMEWORKS and CONTROLS, then compares it with a second framework. A control counts toward a
framework when its letters include the framework's code. Counts with the shipped data:
| Framework | Controls |
|---|---|
| SOC 2 | 61 |
| ISO 27001 | 63 |
| HIPAA | 36 |
| GDPR | 17 |
| PCI DSS | 49 |
| NIST CSF | 46 |
| Pair | Shared | Only first | Only second |
|---|---|---|---|
| SOC 2 + ISO 27001 | 57 | 4 | 6 |
| SOC 2 + HIPAA | 32 | 29 | 4 |
| SOC 2 + PCI DSS | 42 | 19 | 7 |
| ISO 27001 + GDPR | 9 | 54 | 8 |
The view chips switch the table between shared and unique controls. The pairwise matrix under it (matrix in
app/pages/frameworks.vue) links each number to that pair. URL parameters: ?fw=<id>&vs=<id|none> (ids: soc2, iso27001, hipaa,
gdpr, pci, nist); unknown values fall back to SOC 2 compared with ISO 27001. The page keeps the URL in step
with history.replaceState.
Readiness assessment#
ReadinessAssessment.vue steps through eight questions (size, cloud, policies, identity, logging,
vendors, target, deadline); options come from ASSESSMENT. The rules, in assess() in app/utils/assessment.ts:
- Score: the five scored answers add up their
points(20 each at best, 100 in all). - Band: 80+ "Well prepared", 55+ "Solid foundation", 30+ "Early stage", below that "Starting out".
- Gaps: every scored answer below 20 points, ranked by points missing (ties keep question order); 10 or more missing is "High priority", otherwise "Medium priority".
- Range:
low = round((targetLow + gapWeeks) × sizeFactor),high = round((targetHigh + gapWeeks × 1.5) × sizeFactor), with size factors 1, 1.15, 1.35 and 1.6. - Deadline verdict: "Realistic" when
highfits the deadline weeks (13, 26 or 52), "Tight" when onlylowfits, "At risk" otherwise; "No date" with no deadline.
Example: 51–200 people, one cloud set up by hand, draft policies, MFA without SSO, some logs, no vendor reviews, SOC 2 Type II, 3–6 months gives a score of 47 ("Early stage"), 16–24 weeks and "Realistic", with gaps in the order vendor risk reviews, security policies, logging and alerting, identity, cloud configuration.
Answers are saved as they are chosen, so a returning visitor sees their results. The results link to
/contact?fw=<framework>&size=<size>. The copy says plainly that the result is an estimate, not an audit.
Pricing calculator#
The calculator lives in app/pages/pricing.vue; the rules are quote() in app/utils/pricing.ts. The plan follows the employee count (5–2,000): Essentials up to
100, Growth up to 500, Scale up to 2,000.
monthly = base + extra employees × perEmployee + extra frameworks × extraFramework + add-ons
annual per month = round(monthly × (100 − ANNUAL_DISCOUNT) / 100)| Plan | Base | Included employees / frameworks | Per extra employee | Per extra framework | Included add-ons |
|---|---|---|---|---|---|
| Essentials | $690 | 25 / 1 | $8 | $240 | none |
| Growth | $1,450 | 100 / 2 | $6 | $200 | Trust center |
| Scale | $3,200 | 500 / 3 | $4 | $160 | Trust center, vendor risk |
Add-ons: vendor risk $260/month, penetration test coordination $350/month, security awareness training $2 per
employee per month, trust center $120/month. ANNUAL_DISCOUNT is 20.
Examples: the default (60 employees, 1 framework, annual) is $970 a month, or $776 a month billed annually
($9,312 a year). 120 employees, 2 frameworks, vendor risk and training is Growth at $2,070 a month, or
$1,656 a month billed annually ($19,872 a year, saving $4,968). Plan cards show "from" prices: $552, $1,160
and $2,560 billed annually. The "Book a demo with this estimate" link carries ?plan= and ?size= to the demo form.
Integrations directory#
app/pages/integrations.vue filters INTEGRATIONS by category chip and a search over name, summary and category, and
sorts by name or by number of checks. Chip counts follow the search. The filters are saved; ?category=<id>
(cloud, identity, hr, ticketing, code, endpoint) opens a category. Example: Identity and SSO plus
"mfa" shows 2 integrations with 33 automated checks; all 24 integrations run 361 checks.
Trust center#
app/pages/trust.vue renders the control categories (TRUST_CATEGORIES, 20 of 21 passing, 1 being monitored), the
subprocessor table and the report dates (<ReportDate>, see Keeping the demo current). Status pills
always pair color with a symbol and a word ("Passing", "Monitoring"). The document request form is described
under Forms; ?doc=<id> (for example ?doc=soc2) prechecks a document, and the "Request access" buttons next
to each gated document check it and move to the form.
Customer stories#
StoryChart.vue draws two bars per metric from STORIES[].metrics (before in gray, after in teal, each with a
text label and an aria-label), and the chips in app/pages/customers.vue filter stories by framework (each
story's frameworks in STORIES).
Demo booking and time zones#
DemoBooking.vue on /contact runs three steps: team (role, size, at least one framework, timeline), details (name,
work email, company, note) and a time. Slots are DEMO.times in Eastern time (10:00, 11:30, 13:00, 14:30, 16:00)
on the next five business days after today, skipping weekends and US federal holidays computed by rule in
holidaysFor(year) in app/utils/dates.ts (New Year's Day, Martin Luther King Jr. Day, Washington's Birthday, Memorial Day,
Juneteenth, Independence Day, Labor Day, Columbus Day, Veterans Day, Thanksgiving Day, Christmas Day; Saturday
holidays move to Friday, Sunday holidays to Monday). Some slots show as booked by a fixed hash of date and time
(slotTaken()). For example, ?now=2027-11-24T12:00 offers Fri Nov 26 to Thu Dec 2 with the note "Closed Nov
25 for Thanksgiving Day."
Times show in the visitor's own zone (from the browser) with the Richmond time underneath; the "Show times in"
select switches between the visitor's zone and six others. ?tz=America/Los_Angeles (one of the listed zones)
preselects a zone.
The call is routed by SPECIALISTS, first match wins: HIPAA to Soledad Ilunga, PCI DSS to Evander Lachance,
201+ employees to Ravindra Satterwhite, everyone else to Leontyne Fairbairn. ?fw=<id>, ?size=<s1–s4> and
?plan=<essentials|growth|scale> prefill the first step (links from the assessment and pricing pages use them).
Office status#
officeStatus() in app/utils/dates.ts fills the office status in the footer and on /contact with "Open now", "Closed now" or "Closed
today for <holiday>": the Richmond office is open Monday to Friday, 8:30 a.m.–5:30 p.m. ET, and closed on US
federal holidays.
Menu and motion#
SiteHeader.vue toggles the mobile menu (aria-expanded; Esc closes it and returns focus, and it closes when a
link opens a page or the window grows to desktop width). Elements with .reveal fade in as they scroll into view
(app/plugins/reveal.client.ts); everything above a revealed element is revealed too, and reduced motion
shows everything at once. The pulse dot in the status strip stops under reduced motion.
Keeping the demo current#
Nothing dated goes stale:
KEEP_DEMO_CURRENT = trueinapp/utils/dates.tsrolls the trust center dates: the SOC 2 Type II period ends on the latest September 30 whose report has been issued (issued on the fourth Wednesday of October), the ISO 27001 certificate runs in three-year cycles from March 14, 2025, and the latest penetration test is the most recent August. On Nov 3, 2026 that is Oct 1, 2025 – Sep 30, 2026, issued Oct 28, 2026; certified Mar 14, 2025, valid through Mar 13, 2028; August 2026. Set it tofalseto show the fixed dates inFIXED_REPORTS.<DayOffset :offset="-3" format="medium" fallback="3 days ago" />(formatsshort,medium,weekday) shows a date relative to today;<TodayDate>shows today's date; the footer shows this year.- The hero audit window, the document review day and the demo slots are worked out from today.
?now=YYYY-MM-DDTHH:MMpreviews the whole site at another Eastern time, for reviews and screenshots.
The generated HTML contains the dates as they were written (Nov 3, 2026, © 2026); useNow() replaces them in the
browser. For a real site with fixed dates, set KEEP_DEMO_CURRENT to false, edit FIXED_REPORTS, and replace
the <DayOffset> elements in app/pages/platform.vue with real dates.
Forms#
| Form | Page | Validation | Success |
|---|---|---|---|
| Readiness assessment | /assessment |
One answer per question | Results panel |
| Document request | /trust |
At least one document, full name, company, a valid work email (not a personal domain from PERSONAL_DOMAINS), the NDA box when any chosen document needs it |
Reference TR- + 6 characters, the next business day for review, next steps |
| Book a demo | /contact |
Step 1: role, size, a framework, timeline. Step 2: first and last name, valid work email, company. Step 3: a time on the day shown | Reference DM-MMDD-XXXX (slot month and day plus 4 characters), time in Eastern and the visitor's zone, specialist, next steps |
Reference characters avoid 0, O, 1 and I. Errors appear inline next to each field, with aria-invalid on the
input, and focus moves to the first problem.
The demo forms don't send anything until you connect them. onSubmit() in app/components/TrustRequest.vue and
in app/components/DemoBooking.vue has a comment at the spot to add a request; for example with Formspree:
const response = await fetch("https://formspree.io/f/your-id", {
method: "POST",
body: new FormData(form.value!),
headers: { Accept: "application/json" },
});
if (!response.ok) {
// show an error message and stop
}Show the success panel only when the response is OK. For bookings you would
usually call your scheduling tool's API instead and use its confirmed time. The site is static, so a form
service or your own API endpoint receives the data; if you need server code in the same project, add a route in
server/api/ (for example server/api/demo.post.ts) and deploy the pnpm build output to a Node-capable host.
Saved data (localStorage)#
| Key | Shape | Validation on read |
|---|---|---|
wardline:assessment:v1 |
{ "size": "s2", "cloud": "single", …, "target": "soc2t2", "deadline": "d6" } |
Each answer must be a known option key; unknown ones are dropped |
wardline:pricing:v1 |
{ "employees": 120, "frameworks": 2, "addons": ["vendor"], "billing": "annual" } |
Whole numbers in range, known add-on ids, monthly or annual; otherwise defaults |
wardline:integrations:v1 |
{ "category": "identity", "q": "mfa", "sort": "name" } |
Known category or all, a string of up to 60 characters, name or checks |
wardline:demo-draft:v1 |
{ "role", "size", "frameworks", "timeline", "first", "last", "email", "company", "note" } |
Known keys only, strings trimmed to length; removed after booking |
Every read and write goes through safeStorage (app/utils/safeStorage.ts), which falls back to memory when the browser blocks
localStorage (private modes, sandboxed iframes). Keys from storage and the URL are checked with
Object.prototype.hasOwnProperty (the own() helper), so values like ?fw=constructor are ignored. To reset,
clear the site's data or run localStorage.removeItem("wardline:pricing:v1") in the console. Bump the :v1
suffix if you change a shape.
SEO and sharing#
Each page calls usePageSeo({ title, description, page }) (app/composables/usePageSeo.ts), which writes the
<title>, meta description, canonical link, Open Graph tags (og:title, og:description, og:url, og:image,
og:site_name, og:locale en_US) and a Twitter card. theme-color and the favicon are in app.head in
nuxt.config.ts. The 404 page passes noindex: true (robots noindex, no canonical).
Canonical and Open Graph URLs are absolute: set SITE_URL when you build (SITE_URL=https://www.example.com pnpm generate) or change the default in runtimeConfig in nuxt.config.ts. Suggestions: add a sitemap module and a
public/robots.txt; a SoftwareApplication or Organization JSON-LD block (with useHead()) is a good addition
once you have real details.
Accessibility#
- Skip link, landmarks (
header,nav,main,footer), oneh1per page and ordered headings. - Visible focus rings in teal (bright teal on dark bands).
- Tabs follow the ARIA tabs pattern; toggle buttons use
aria-pressed; the menu usesaria-expanded. - Status is never shown by color alone: each pill has a symbol and a word, and charts have text labels and
aria-labelsummaries. - Live regions announce result counts, overlap text and estimates; errors are inline and linked with
aria-describedby. - Scrollable tables are keyboard-focusable; reduced motion turns off reveals, the pulse and transitions.
When editing, keep one h1 per page, label every new input and keep body text at the slate tokens or darker.
Building and deploying#
pnpm generate # static site in .output/public/
pnpm preview # serve it locallyDeploy .output/public/ to any static host (Netlify, Cloudflare Pages, GitHub Pages, S3) or import the repository
into Vercel or Netlify, which detect Nuxt. Pages are written as pricing.html, trust.html and so on, and links
point to /pricing without a trailing slash; most static hosts serve /pricing from pricing.html
automatically, and 404.html is the not-found page. Every page is listed in routes in nuxt.config.ts; add new
pages there so they are generated.
Tailwind is compiled by @tailwindcss/vite during the build, so there is no separate CSS step. It scans app/
(and nuxt.config.ts for the <body> classes), including class names inside strings in components and
app/data/*.ts, so write class names out in full there.
For a site in a sub-folder, set DEMO_BASE_PATH when building (DEMO_BASE_PATH=/compliance pnpm generate): it
becomes app.baseURL in nuxt.config.ts, and every <NuxtLink> and asset URL includes it. pnpm build:demo does
this for the freeframe.dev demo and writes demo/, which you can delete.
Need server features (a real form handler)? pnpm build makes a Node.js server build in .output/ (pages
are still prerendered); add routes in server/api/ and deploy to a Node-capable host.
pnpm lint runs ESLint 10 with @nuxt/eslint. tsconfig.json only references the configs Nuxt writes to
.nuxt/, so pnpm typecheck checks the app and config with Nuxt's strict settings. typescript is aliased to
@typescript/typescript6, the TypeScript 6 API that vue-tsc needs.
Links to the same page#
In the HTML version every link loads a page. Here links are client-side navigations, and the frameworks page and
the platform tour keep their state in the address with history.replaceState. So that a link to the page already
showing (the header's Platform link on a tour tab, a footer framework link or a matrix cell on the frameworks
page) still starts that page afresh, app/app.vue keys each page by its path and query, and
app/plugins/same-page.client.ts restarts the page for links to the same path. Links to a #section of the page
as it is (the pricing cards' "Estimate" links) just scroll.
Troubleshooting#
- A Tailwind class has no effect. Tailwind only generates classes it finds written out in the source. A class
built from pieces (
"status-" + tone) is never found; write the full names, asReadinessAssessment.vuedoes for the verdict pill. - Dates and the office status show the written text for a moment. They depend on the visitor's clock, so they are filled in once the page has loaded; the generated HTML keeps the text written in the page.
- The assessment opens on the results. Answers are saved; use "Start over", or remove
wardline:assessment:v1. - Demo slots are on the wrong days. Dates come from the visitor's clock in Eastern time; check
?now=isn't in the URL, and editholidaysFor()inapp/utils/dates.tsfor your own office closures. - Counts on the frameworks page changed. They come from
CONTROLS; check the letters on any control you edited. - Report dates don't match your real report. Set
KEEP_DEMO_CURRENTtofalseand fillFIXED_REPORTS. - Links or images break when the site is in a sub-folder. Build with
DEMO_BASE_PATH, write internal links as<NuxtLink to="/page">and referencepublic/files with$config.app.baseURL. - Social previews point to
wardline.example. SetSITE_URLto your live domain when building.
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. Anek Latin,
Source Sans 3 and Roboto Mono are included under the SIL Open Font License (see app/assets/fonts/*-OFL.txt).
Wardline, its customers, integrations, subprocessors and people are fictional.