Overview#
Prism is a marketing website for an AI startup, built for Prism, a fictional research assistant made by Prism Labs (San Jose and Salt Lake City). The product answers questions from a team's docs, tickets and the web with citations, in an app and through an API. The site has a home page with a streaming playground demo, a product page, API docs, pricing with a cost calculator, a changelog, a company and careers page and a sales form.
This is the Nuxt version (Nuxt 4, Vue 3, TypeScript, Tailwind CSS v4), version 1.0.1, ported page for page from
the HTML template. The design is dark-first: near-black surfaces, a hairline grid, film grain and spectrum
gradients taken from light passing through a prism. nuxt generate turns it into static files. Live demo:
https://freeframe.dev/templates/prism
The site is a front end only. The playground streams pre-written answers, and the forms don't send anything until you connect your own services (see Forms).
Quick start#
Requirements: Node.js 24 or newer and pnpm (npm works too).
pnpm install # also runs `nuxt prepare` (types and the ESLint config in .nuxt/)
pnpm dev # http://localhost:3000Other scripts:
| Script | What it does |
|---|---|
pnpm generate |
Static site in .output/public/ (nuxt generate): every page prerendered to HTML |
pnpm build |
A Node.js server build in .output/ (nuxt build), for hosts that run Nuxt as a server |
pnpm preview |
Serve the last build locally |
pnpm lint |
ESLint (@nuxt/eslint) |
pnpm typecheck |
nuxt prepare and vue-tsc |
pnpm build:demo |
Generate for freeframe.dev under /demos/nuxt/prism into demo/ |
Pages#
| Route | File | Purpose and features |
|---|---|---|
/ |
app/pages/index.vue |
Hero, streaming playground, logo strip, feature grid, how it works, benchmark chart, testimonials, call to action |
/product |
app/pages/product.vue |
Search, write, briefs and analyze sections with UI mock-ups, integrations grid (#integrations), security badges and data retention table (#security), model cards and latency chart (#models) |
/developers |
app/pages/developers.vue |
Example response, three-step quickstart with code tabs (#quickstart), endpoint list (#endpoints), SDK cards with copy buttons (#sdks), rate limits table (#rate-limits) |
/pricing |
app/pages/pricing.vue |
Monthly / annual switch, three plans, API price table, cost calculator (#calculator), FAQ accordion (#faq) |
/changelog |
app/pages/changelog.vue |
Dated releases on a timeline, topic filter with counts (/changelog?tag=api) |
/about |
app/pages/about.vue |
Story, figures, values, team, open roles with a team filter (/about?team=research#careers) |
/contact |
app/pages/contact.vue |
What happens next, offices, sales form (/contact?topic=security preselects the topic) |
| 404 | app/error.vue |
Prism illustration and links back to the site (generated as 404.html, noindex) |
Project structure#
nuxt.config.ts base path, head defaults (fonts, theme color, the js class), SITE_URL, prerendered routes
app/
app.vue <NuxtLayout><NuxtPage /></NuxtLayout>, pages keyed so a link to the open page restarts it
layouts/default.vue skip link, header, the page, footer, the live region and the back-to-top button
pages/ one file per page (index.vue, pricing.vue...)
error.vue the 404 page
components/
SiteHeader.vue sticky header, current page, mobile menu
SiteFooter.vue footer columns, status pill, NewsletterForm.vue and the copyright year
PrismMark.vue the logo mark
PlaygroundDemo.vue the streaming playground
BenchmarkChart.vue benchmark figure: SVG chart, legend and table
CostCalculator.vue seats + tokens estimate
FaqAccordion.vue pricing FAQ
CodeTabs.vue language tabs for code samples; CopyButton.vue and CopyIcon.vue copy to the clipboard
ContactForm.vue sales form; FieldError.vue shows a field's message, SuccessBox.vue replaces a sent form
AvatarArt.vue drawn portraits (testimonials, team)
BackToTop.vue back-to-top button
composables/
useValidation.ts form rules (required, email, work email, min length, groups) and messages
useFilter.ts topic filters for releases and roles
useCodeLang.ts the remembered code language
usePageSeo.ts, useCurrentPage.ts, useYear.ts, useAsset.ts
data/ content arrays and settings (see Content)
utils/ safeStorage.ts, url.ts, announce.ts, copy.ts, format.ts (auto-imported)
plugins/
reveal.client.ts scroll reveals
same-page.client.ts links to the page already showing start it again at the top
stable-build.server.ts reproducible builds
assets/css/main.css Tailwind import, @font-face, @theme tokens, component classes
assets/fonts/ woff2 files and their OFL licenses
public/img/
favicon.svg the Prism mark
prism-beam.svg prism and light-beam illustration (CTA panels and 404)
og-image.png, og-image.svg social sharing image (1200×630) and its sourceCustomizing#
Brand and colors#
The design tokens live in the @theme block of app/assets/css/main.css. Every utility such as bg-ink-900,
text-muted or text-brand-300 comes from them.
| Token | Default | Used for |
|---|---|---|
--color-ink-950 |
#05060a |
Page background |
--color-ink-900, --color-ink-850, --color-ink-800 |
#0a0c12, #0f1219, #151924 |
Cards, inputs, raised panels |
--color-fg |
#eef0f6 |
Headings, body text, primary buttons |
--color-muted |
#a6acbe |
Secondary text |
--color-subtle |
#8b92a7 |
Captions and meta text (still AA on every surface) |
--color-brand-200 … --color-brand-700 |
aqua scale | Links, focus rings, highlights, citation chips |
--color-ray-rose, -amber, -lemon, -mint, -sky, -violet |
the spectrum | Logo, gradients, chart bars, icons |
The spectrum gradient used for dividers and tabs is the --spectrum custom property in :root; the gradient
text class .text-spectrum and the .glass card border are defined in the same file. To change the brand
color, edit the brand scale:
@theme {
--color-brand-300: #a5b4fc;
--color-brand-400: #818cf8;
--color-brand-500: #6366f1;
}Prism is a dark-only design (color-scheme: dark in main.css and in the color-scheme meta tag in
nuxt.config.ts). There is no light theme or toggle. To lighten it, change the ink and text tokens together and
check contrast again. A few colors are written into SVG art and the benchmark chart
(app/components/BenchmarkChart.vue, app/data/benchmarks.ts); update those too.
Typography#
Three fonts are self-hosted from app/assets/fonts/ with @font-face rules at the top of main.css, using the
Latin woff2 files Google Fonts serves: Geist (text and headings), Geist Mono (labels, code, numbers) and
Instrument Serif (the italic accent word in headlines, class .serif, and two logo wordmarks). Each keeps its
real family name, so the stacks --font-sans, --font-mono and --font-serif in @theme and the
font-family attributes in the SVG logos can name them directly. To swap a font, put its woff2 file in
app/assets/fonts/, change the @font-face rule and update the token.
Content#
Page copy is in each page's .vue file. The content behind lists and interactive parts lives in app/data/:
| File | Export | What it holds |
|---|---|---|
playground.ts |
PLAYGROUND |
Example prompts with their answers, sources, seconds and token counts |
PLAYGROUND_FALLBACK |
The answer for any prompt that isn't an example ({prompt} is replaced) |
|
MODELS |
Playground models: milliseconds per token and a time factor for the meta line | |
benchmarks.ts |
BENCHMARKS |
Chart models (name, color) and tests (name, scores in percent) |
pricing.ts |
PRICING |
Seat prices, included tokens, input share, API prices per model, slider steps, enterprise threshold |
faq.ts |
FAQ |
Pricing questions and answers (an answer can hold one link) |
code-samples.ts |
REQUEST, STREAM |
Highlighted code for the two tabbed quickstart cards |
changelog.ts |
TOPICS, RELEASES |
Filter topics (label and dot color) and releases |
company.ts |
TEAM, ROLE_TEAMS, ROLES |
Team cards with their drawn portraits, teams for the filter and open roles |
testimonials.ts |
TESTIMONIALS |
Home page quotes and portraits |
contact.ts |
CONTACT_TOPICS, TEAM_SIZES |
Choices in the sales form |
nav.ts |
mainNav, salesLink, footerColumns |
Header menu and footer columns |
Plan cards, the API price table, endpoints, SDK cards and the product page's integrations are written in the page files, as in the HTML version.
To add a playground example, add an object to PLAYGROUND. In answer text, **bold** makes emphasis and [n]
cites source n:
{
id: "churn",
label: "Explain last month’s churn",
prompt: "Why did churn go up in September?",
seconds: 1.8,
tokens: 120,
sources: [{ kind: "Analytics", title: "Churn dashboard, September" }],
answer: [
{ p: "Churn rose to **3.9%**, mostly from monthly plans [1]." },
{ ul: ["First point [1].", "Second point [1]."] },
],
},The example buttons are generated from the array. To add a release, add an entry at the top of RELEASES
(summary and points are HTML, so code can be wrapped in <code class="text-[0.9em] text-fg">):
{
date: "2026-10-22",
dateLabel: "October 22, 2026",
version: "v2.1",
title: "Shared briefs",
tags: ["product"],
summary: "Briefs can now be shared with a link.",
points: ['New <code class="text-[0.9em] text-fg">share</code> field on <code class="text-[0.9em] text-fg">/v1/briefs</code>.'],
},Open roles are { title, team, location } objects in ROLES; team must be a ROLE_TEAMS value.
Images and artwork#
There are no photos. Everything is SVG or CSS:
- Logo —
app/components/PrismMark.vue(header and footer); the same mark ispublic/img/favicon.svg. - Customer logos — inline SVG wordmarks in the logo strip of
app/pages/index.vue. Each hasrole="img"and anaria-label; keep those when you replace them. The list is drawn twice so the strip can loop. - Avatars —
app/components/AvatarArt.vuedraws the portraits fromavatarintestimonials.tsandcompany.ts. To use photos, replace<AvatarArt>with an<img>of the same size class (square, at least 400×400 px for the team, 80×80 px for testimonials) and a realalt. - Prism illustration —
public/img/prism-beam.svg(800×400), used in the call-to-action panels and the 404 page throughasset("img/prism-beam.svg")(useAsset()), which adds the base path. - Product mock-ups — the playground, citation preview, search results, draft editor, briefs and charts are
markup and inline SVG, marked
aria-hidden="true"where they are decorative. - Background — the grid (
.bg-grid), glow (.aurora) and film grain (.grainon<body>, set innuxt.config.ts, an SVG noise filter) are classes inmain.css. Removegrainto drop the grain. - Social image —
public/img/og-image.png(1200×630). Editpublic/img/og-image.svgand export it to PNG at 1200×630 to change it; most social sites don't display SVG.
Navigation, header and footer#
The header (app/components/SiteHeader.vue) and footer (app/components/SiteFooter.vue) render on every page
from app/layouts/default.vue. Menu items come from mainNav and the footer columns from footerColumns in
app/data/nav.ts. To add a page, create app/pages/<page>.vue (its root is <main id="main">, and it calls
usePageSeo()), add its route to routes in nuxt.config.ts so it is prerendered, then add it to mainNav
(the mobile menu uses the same list plus "Talk to sales"). The current page gets aria-current="page", which
also styles it. The header becomes frosted after scrolling (class is-scrolled).
Features#
Playground#
On the home page, #playground streams a pre-written answer token by token: sources appear first ("Searching 4
sources…"), then the text with a caret, numbered citation links to the sources, and a meta line with model, time
and tokens. The first example runs once when the playground scrolls into view.
- Code:
app/components/PlaygroundDemo.vue; data inPLAYGROUND,PLAYGROUND_FALLBACKandMODELS. - Controls: the example buttons, the prompt (
#pg-prompt, run with Ctrl/Cmd + Enter), the Run button that becomes Stop while streaming, and the model select (#pg-model), which changes the streaming speed viamsPerToken. Typing an example's question (in any case) plays that example. - Any other prompt gets
PLAYGROUND_FALLBACK, with the visitor's text inserted as text (Vue escapes it). - Connecting a real model: call your own backend from
run()(never call an AI API with a secret key from the browser), for example a Nuxt server route that streams, and feed each chunk to the writer. The answer is kept as blocks of text runs (blocks), so a real stream can append tokens the same way the timer does. - Accessibility: the answer area has
aria-live="polite"andaria-busy="true"while streaming, so screen readers read the finished answer once. Example buttons usearia-pressed. With reduced motion the whole answer appears at once.
Benchmark chart#
app/components/BenchmarkChart.vue draws grouped horizontal bars from BENCHMARKS at the box's real width and
redraws on resize, so labels stay readable on phones; the legend and the table inside "View as a table" come
from the same data. The first model uses the spectrum gradient (color: "spectrum"); others take any CSS color.
The SVG has role="img" and an aria-label that lists every value. Bars grow in when the figure is revealed
(.bar rules in main.css), instantly with reduced motion.
Pricing and calculator#
app/pages/pricing.vue holds the billing period (annual, on by default):
- Billing switch
#billing-toggle(role="switch",aria-checked). The Team price and its note follow it. - Calculator (
app/components/CostCalculator.vue) with#calc-seats(1–250),#calc-tokens(steps fromPRICING.tokenSteps, in millions) and the model radios. The estimate is seats × seat price (fromPRICING.seat, following the switch) plus billable tokens × a blended price (inputShareinput, the rest output), afterincludedTokensMfree tokens. FromenterpriseSeatsseats a note suggests Enterprise pricing.
The plan cards and the API table are written in pricing.vue, so keep them in step with PRICING when prices
change. The sliders announce readable values (aria-valuetext), and the total is announced politely 0.7 s after
changes stop.
Code tabs and copy buttons#
On /developers, each CodeTabs card has a role="tablist" with cURL, JavaScript and Python tabs and
role="tabpanel" panels. Choosing a language switches every card on the page and is remembered (see
Saved data); useCodeLang() holds the shared value. Arrow keys, Home and End move
between tabs.
CopyButton copies its text prop (the SDK install commands); without one it copies the visible code block in
its [data-tabs] or [data-copy-scope] card. The label changes to "Copied" for 1.8 s and the live region
announces it. Code is colored with spans (t-k, t-s, t-n, t-c, t-f, t-p) styled in main.css; when
you change a sample in app/data/code-samples.ts you can drop the spans and keep plain (escaped) text. Code
blocks are focusable so keyboard users can scroll them.
Filters#
useFilter() (app/composables/useFilter.ts) powers the changelog topics and the careers team filter. It gets
every item's tags, the URL parameter (tag or team) and the noun for announcements; the page renders the
buttons with counts (aria-pressed), hides items that don't match and shows the empty message when nothing
does. The choice is kept in the URL (/changelog?tag=security) with history.replaceState, so filtered views
can be shared; unknown values show everything. "Showing 4 releases" is announced after each click.
FAQ accordion#
app/components/FaqAccordion.vue renders FAQ. Each button has aria-expanded and aria-controls pointing at
its panel. One answer is open at a time; store a set of open indexes instead of one number to allow several.
Without JavaScript every answer is visible.
Header, menu, reveals and back to top#
The mobile menu (#menu-toggle, #mobile-menu) uses aria-expanded, closes on Escape (returning focus to the
button), on navigation and when the window grows to desktop width. Elements with .reveal fade up as they enter
the viewport (reveal-d1 to reveal-d3 add a delay); app/plugins/reveal.client.ts also reveals everything
above, and nothing is hidden for reduced motion or without JavaScript. Don't put .reveal on an element with a
:class binding that changes, or Vue would drop the is-visible class the plugin adds. The logo strip scrolls
slowly (paused on hover, static for reduced motion). A back-to-top button (app/components/BackToTop.vue)
appears after one screen of scrolling; remove it from default.vue and delete the .back-to-top styles to drop
it.
Things that read the browser#
The generated HTML shows the copy as written. The copyright year (useYear()), the code language, the filters
from the URL and the sales form topic from ?topic= are applied after the page has loaded, so the static HTML
and the page Vue takes over always match.
Forms#
There are two forms. Both use useValidation() from app/composables/useValidation.ts, which reads the rules
from each field's attributes (required, type, data-validate, minlength, data-label, data-msg,
data-group): messages appear under the field (<FieldError>), linked with aria-describedby and
aria-invalid, focus moves to the first problem, and errors update as the visitor types.
| Form | Where | Checks |
|---|---|---|
| Newsletter | app/components/NewsletterForm.vue (footer of every page) |
Email required and well-formed |
| Talk to sales | app/components/ContactForm.vue (/contact) |
First and last name, work email (data-validate="work-email" rejects free email domains), company, team size, at least one "Interested in" box, optional message of at least 20 characters, consent box |
/contact?topic=careers (or sales, trial, enterprise, security, api, partnerships, other)
preselects the topic; the options are CONTACT_TOPICS in app/data/contact.ts.
On success each form is replaced by a confirmation (SuccessBox, role="status", focused). The newsletter form
says "You’re on the list" and asks the subscriber to look out for a confirmation email. The sales form thanks the
visitor by first name, says a real person will reply within one business day (as the page promises) and shows a
short reference made in the browser by makeRef() in ContactForm.vue: PL- and four characters with no 0/O or
1/I, like PL-7K2Q. If your form service or CRM returns its own reference, show that instead, and change the reply
time if yours differs.
The forms don't send anything until you connect them. To connect a form service such as
Formspree, change the callback passed to useValidation():
const v = useValidation(async (form) => {
const res = await fetch("https://formspree.io/f/your-id", {
method: "POST",
body: new FormData(form),
headers: { Accept: "application/json" },
});
if (!res.ok) return; // show an error message here
const first = (form.elements.namedItem("first_name") as HTMLInputElement).value.trim();
sent.value = { first, ref: makeRef("PL") }; // or the reference your service returns
});Every field has a name, so FormData includes it. With pnpm build (a server build) you can post to a Nuxt
server route instead (server/api/contact.post.ts).
Saved data (localStorage)#
| Key | Value | Validation |
|---|---|---|
prism-code-lang |
curl, js or python |
useCodeLang() ignores anything else and keeps cURL |
It only stores the preferred code language on /developers. Reading and writing go through safeStorage
(app/utils/safeStorage.ts), which falls back to memory, so blocked storage only means the choice isn't
remembered after a reload. To reset it, run localStorage.removeItem("prism-code-lang") in the console; to stop
saving, remove the safeStorage.set() call in useCodeLang().
SEO and sharing#
Each page calls usePageSeo({ title, description, page }) (app/composables/usePageSeo.ts), which sets the
title, description, canonical URL and Open Graph and Twitter tags. Canonical and image URLs are absolute: set
SITE_URL when building.
SITE_URL=https://prism.example.com pnpm generateThe 404 page is noindex. Add a sitemap and robots.txt (files in public/ or a module) if you want them. There
is no structured data; Organization and SoftwareApplication JSON-LD are good additions.
Accessibility#
Built in:
- Skip link to
#main, landmarks (header, labelednavs,main,footer) and oneh1per page with ordered headings. - Visible focus rings (
:focus-visiblein brand aqua) and color contrast that passes WCAG AA on every surface. - ARIA patterns: menu button with
aria-expanded, tabs with rovingtabindex, a switch (role="switch"), accordion buttons,aria-pressedfilters and examples, live regions for the playground, calculator, filters and copy buttons, and labeled scrollable tables (role="region",tabindex="0"). prefers-reduced-motion: no streaming animation, no marquee, no reveals, instant back-to-top.- Decorative SVG and mock-ups are
aria-hidden; meaningful graphics (logos, charts) have labels.
When editing, keep one h1 per page, give every new input a <label>, keep alt="" on decorative images, and
check contrast if you change the ink, muted or subtle tokens.
Building and deploying#
pnpm generate # static site in .output/public/
pnpm preview # serve it locallynuxt generate prerenders every route listed in routes in nuxt.config.ts as <page>.html (pricing.html,
linked as /pricing), plus 404.html from app/error.vue. Next to each page is a folder with its
_payload.json, which Nuxt loads when you move between pages. Upload .output/public/ to any static host
(Netlify, Cloudflare Pages, GitHub Pages, S3, Vercel). Hosts that don't map /pricing to pricing.html on their
own need that rewrite (Vercel: "cleanUrls": true); most serve 404.html for unknown URLs.
To serve the site from a sub-folder, set DEMO_BASE_PATH, which becomes Nuxt's app.baseURL:
DEMO_BASE_PATH=/prism pnpm generatepnpm build makes a Node.js server build instead (run it with node .output/server/index.mjs); the pages are
still prerendered, and you can add server routes (a real model behind the playground, the forms). demo/ is the
freeframe.dev demo build and can be deleted.
TypeScript setup#
pnpm typecheck runs vue-tsc against the configs Nuxt writes to .nuxt/ (tsconfig.json references them).
typescript is aliased to @typescript/typescript6, the TypeScript 6 API that vue-tsc and typescript-eslint
load; replace it with typescript@7 once they support it.
Troubleshooting#
- The playground answer appears all at once. Your system has "reduce motion" turned on; streaming is intentionally skipped.
- A new example doesn't match when typed. Typed prompts match an example only if the text equals its
prompt(ignoring case and surrounding spaces); anything else gets the fallback answer. - Calculator totals don't match the plan cards. The calculator reads
PRICING; the cards and the API table are written inpricing.vue. Update both. - Copy buttons say "Press Ctrl+C". The browser blocked clipboard access. It works on
localhostand HTTPS. - A Tailwind class written next to an expression has no effect. Keep class names whole
(
`h-4 w-4 ${on ? "hidden" : ""}`), otherwise Tailwind can't find them. pnpm lintfails with a missing.nuxt/eslint.config.mjs. Runpnpm install(orpnpm typecheck) once;nuxt preparewrites it.- The demo's links or images break under a sub-folder. Build with
DEMO_BASE_PATHset to that folder.
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. Geist, Geist Mono
and Instrument Serif are under the SIL Open Font License 1.1 (app/assets/fonts/*-OFL.txt). Prism, Prism
Labs, their customers and team are fictional.