Freeframe

Documentation

Prism

v1.0.1Updated Nov 13, 2026A sleek, dark-first website for an AI startup, with a live streaming playground and a usage cost calculator.

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

sh
pnpm install      # also runs `nuxt prepare` (types and the ESLint config in .nuxt/)
pnpm dev          # http://localhost:3000

Other 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 source

Customizing#

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:

css
@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:

ts
{
  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">):

ts
{
  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 is public/img/favicon.svg.
  • Customer logos — inline SVG wordmarks in the logo strip of app/pages/index.vue. Each has role="img" and an aria-label; keep those when you replace them. The list is drawn twice so the strip can loop.
  • Avatars — app/components/AvatarArt.vue draws the portraits from avatar in testimonials.ts and company.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 real alt.
  • Prism illustration — public/img/prism-beam.svg (800×400), used in the call-to-action panels and the 404 page through asset("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 (.grain on <body>, set in nuxt.config.ts, an SVG noise filter) are classes in main.css. Remove grain to drop the grain.
  • Social image — public/img/og-image.png (1200×630). Edit public/img/og-image.svg and export it to PNG at 1200×630 to change it; most social sites don't display SVG.

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 in PLAYGROUND, PLAYGROUND_FALLBACK and MODELS.
  • 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 via msPerToken. 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" and aria-busy="true" while streaming, so screen readers read the finished answer once. Example buttons use aria-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 from PRICING.tokenSteps, in millions) and the model radios. The estimate is seats × seat price (from PRICING.seat, following the switch) plus billable tokens × a blended price (inputShare input, the rest output), after includedTokensM free tokens. From enterpriseSeats seats 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():

ts
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.

sh
SITE_URL=https://prism.example.com pnpm generate

The 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, labeled navs, main, footer) and one h1 per page with ordered headings.
  • Visible focus rings (:focus-visible in brand aqua) and color contrast that passes WCAG AA on every surface.
  • ARIA patterns: menu button with aria-expanded, tabs with roving tabindex, a switch (role="switch"), accordion buttons, aria-pressed filters 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#

sh
pnpm generate     # static site in .output/public/
pnpm preview      # serve it locally

nuxt 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:

sh
DEMO_BASE_PATH=/prism pnpm generate

pnpm 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 in pricing.vue. Update both.
  • Copy buttons say "Press Ctrl+C". The browser blocked clipboard access. It works on localhost and 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 lint fails with a missing .nuxt/eslint.config.mjs. Run pnpm install (or pnpm typecheck) once; nuxt prepare writes it.
  • The demo's links or images break under a sub-folder. Build with DEMO_BASE_PATH set 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.

Search documentation

Search the guides and every template's documentation