Overview#
Prism is a marketing website for an AI startup, built for Prism, a fictional research assistant made by Prism Labs (Toronto and London). 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 Astro version: Astro 7, TypeScript and Tailwind CSS v4, with no UI framework, building to plain static files. The design is dark-first: near-black surfaces, a hairline grid, film grain and spectrum gradients taken from light passing through a prism. This guide covers version 1.0.0. See it live at freeframe.dev/templates/prism.
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/prism into demo/ (only used for the demo on freeframe.dev) |
Pages#
| File | URL | Purpose and notable sections |
|---|---|---|
src/pages/index.astro |
/ |
Hero, streaming playground, logo strip, feature grid, how it works, benchmark chart, testimonials, call to action |
src/pages/product.astro |
/product |
Search, write, briefs and analyse sections with UI mock-ups, integrations grid (#integrations), security badges and data retention table (#security), model cards and latency chart (#models) |
src/pages/developers.astro |
/developers |
Example response, three-step quickstart with code tabs (#quickstart), endpoint list (#endpoints), SDK cards with copy buttons (#sdks), rate limits table (#rate-limits) |
src/pages/pricing.astro |
/pricing |
Monthly / annual switch, three plans, API price table, cost calculator (#calculator), FAQ accordion (#faq) |
src/pages/changelog.astro |
/changelog |
Dated releases on a timeline, topic filter with counts (/changelog?tag=api) |
src/pages/about.astro |
/about |
Story, figures, values, team, open roles with a team filter (/about?team=research#careers) |
src/pages/contact.astro |
/contact |
What happens next, offices, sales form (/contact?topic=security preselects the topic) |
src/pages/404.astro |
/404 |
Prism illustration and links back to the site |
Project structure#
prism/
astro.config.mjs site URL, base path, file-style output, Tailwind plugin
src/
pages/ one .astro file per page (see above)
layouts/Layout.astro <html>, <head> metadata, the "js" class script, header, footer, scripts
components/
SiteHeader.astro logo, main menu, "Talk to sales" and "Start free", mobile menu
SiteFooter.astro footer columns, status pill, newsletter form, copyright
Logo.astro the faceted prism mark (inline SVG)
Avatar.astro drawn portrait used by testimonials and the team grid
CodeTabs.astro code card with cURL / JavaScript / Python tabs and a copy button
data/
nav.ts header and footer links
playground.ts playground examples, fallback answer and model speeds
benchmarks.ts benchmark chart models and scores
pricing.ts calculator prices (PRICING), plan cards (PLANS), API price table
faq.ts pricing FAQ
changelog.ts releases (RELEASES) and filter topics (TOPICS)
company.ts team (TEAM), open roles (ROLES) and role filters (ROLE_TEAMS)
testimonials.ts home page quotes
product.ts integrations, data retention rows and model cards
api.ts endpoint list and SDK cards
code-samples.ts highlighted code for the developers page
contact.ts topics and team sizes in the sales form
lib/
site.ts site name, theme colour and the url() helper
icons.ts repeated inline SVG icons
scripts/
main.ts all interactive features
back-to-top.ts the back-to-top button
styles/global.css Tailwind: design tokens and component classes
public/img/ favicon, prism illustration and social image
README.md, DOCS.md, CHANGELOG.mdCustomising#
Brand and colours#
The design tokens live in the @theme block of src/styles/global.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 colour, edit the brand scale (the dev server picks it up immediately):
@theme {
--color-brand-300: #a5b4fc;
--color-brand-400: #818cf8;
--color-brand-500: #6366f1;
}Prism is a dark-only design (color-scheme: dark in global.css and a color-scheme meta tag in
src/layouts/Layout.astro). There is no light theme or toggle. To lighten it, change the ink and text tokens
together and check contrast again. The browser bar colour is themeColor in src/lib/site.ts.
Typography#
Three Google Fonts are loaded by a <link> in src/layouts/Layout.astro: Geist (text and headings),
Geist Mono (labels, code, numbers) and Instrument Serif italic (the accent word in headlines, class
.serif). The stacks are --font-sans, --font-mono and --font-serif in @theme, each with system
fallbacks. To swap a font, change the Google Fonts URL in the layout and the matching token.
Content#
Page text is in the .astro files in src/pages/. Lists and the data behind the interactive parts are
TypeScript arrays in src/data/, rendered with .map():
| File | Export | What it holds |
|---|---|---|
playground.ts |
PLAYGROUND |
The example prompts and their streamed answers, sources, seconds and token counts |
playground.ts |
PLAYGROUND_FALLBACK |
The answer shown for any prompt that isn't an example ({prompt} is replaced) |
playground.ts |
MODELS |
Playground models: milliseconds per token and a time factor for the meta line |
benchmarks.ts |
BENCHMARKS |
Chart models (name, colour) and tests (name, scores in percent) |
pricing.ts |
PRICING |
Calculator: seat prices, included tokens, input share, API prices per model, slider steps, enterprise threshold |
pricing.ts |
PLANS, API_PRICES |
The three plan cards and the "Pay as you go" table |
faq.ts |
FAQ |
Pricing questions and answers (answers are HTML, so they can hold links) |
changelog.ts |
RELEASES, TOPICS |
Releases, newest first, and the topic filter buttons |
company.ts |
TEAM, ROLES, ROLE_TEAMS |
Team grid, open roles and the role filter buttons |
testimonials.ts |
TESTIMONIALS |
Home page quotes |
product.ts |
INTEGRATIONS, RETENTION, MODEL_CARDS |
Product page grid, data retention table and model cards |
api.ts |
ENDPOINTS, SDKS |
Developers page endpoint list and SDK cards |
code-samples.ts |
RESPONSE, EXPORT_KEY, REQUEST, STREAM |
Highlighted code on the developers page |
contact.ts |
CONTACT_TOPICS, TEAM_SIZES |
Choices in the sales form |
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 this array, so nothing else needs to change.
To add a release, put a new object at the top of RELEASES in src/data/changelog.ts. tags are values from
TOPICS; the filter counts update by themselves:
{
date: "2026-11-05",
dateLabel: "November 5, 2026",
version: "v2.1",
title: "Briefs in your team chat",
tags: ["product", "connectors"],
summary: "Scheduled briefs can now post to a chat channel.",
points: ["Pick a channel per brief.", "Replies in the thread start a new question."],
},Open roles are objects in ROLES (src/data/company.ts) with a team from ROLE_TEAMS. Plan prices are in
PLANS and the calculator's prices in PRICING (see Pricing and calculator).
Images and artwork#
There are no photos. Everything is SVG or CSS:
- Logo — the faceted triangle in the header and footer is
src/components/Logo.astro; the same mark is inpublic/img/favicon.svg. - Customer logos — inline SVG wordmarks in the logo strip of
src/pages/index.astro. Each hasrole="img"and anaria-label; keep those when you replace them. - Avatars —
src/components/Avatar.astrodraws each portrait fromhuesand ahairpath in the data (TEAM,TESTIMONIALS). To use photos, replace<Avatar … />with an<img>of the same size classes (square, at least 400×400 px for the team, 80×80 px for testimonials), put the files inpublic/img/and link them withurl("/img/name.jpg"), with a realalt. - Prism illustration —
public/img/prism-beam.svg(800×400), used in the call-to-action panels and the 404 page. - Product mock-ups — the playground, citation preview, search results, draft editor, briefs and charts are
HTML and inline SVG, marked
aria-hidden="true"where they are decorative. - Background — the grid (
.bg-grid), glow (.aurora) and film grain (.grain, an SVG noise filter) are CSS classes inglobal.css. Removegrainfrom<body>in the layout to drop the grain. - Social image —
public/img/og-image.png(1200×630) is what the pages reference. Editpublic/img/og-image.svgand export it to PNG at 1200×630; most social sites don't display SVG.
Navigation, header and footer#
Header links are mainNav in src/data/nav.ts (desktop and mobile menus), plus salesLink ("Talk to sales",
shown beside "Start free" and at the end of the mobile menu). A page highlights its link by passing the
matching key as current to <Layout>:
<Layout title="Pricing — Prism" description="…" page="pricing" current="pricing">That sets aria-current="page", which also styles the link. The footer columns are footerColumns in the same
file. To add a page, create src/pages/<name>.astro with a <Layout> and its content, then add a link to
mainNav. The header becomes frosted after scrolling (class is-scrolled, set in section 3 of
src/scripts/main.ts).
Features#
All interactive code is in src/scripts/main.ts (plus src/scripts/back-to-top.ts), imported once by the
layout. Each feature starts only when its markup is on the page.
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:
initPlayground()in section 4 ofsrc/scripts/main.ts; data insrc/data/playground.ts. - 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. - Any other prompt gets
PLAYGROUND_FALLBACK, with the visitor's text inserted as plain text (never as HTML). - Connecting a real model: replace the body of
run()with a call to your own backend (never call an AI API with a secret key from the browser) and feed each streamed chunk to the writer'sstep()logic, or append text to#pg-outputdirectly. - 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 and nothing auto-plays.
Benchmark chart#
initBenchmarks() (section 5) draws grouped horizontal bars into #bench-chart from BENCHMARKS, a legend into
#bench-legend and a data table into #bench-table (inside "View as a table"). The SVG is redrawn on resize so
labels stay readable on phones. The first model uses the spectrum gradient (color: "spectrum"); others take
any CSS colour. The SVG has role="img" and an aria-label that lists every value. Bars grow in when the
figure is revealed (.bar rules in global.css), instantly with reduced motion.
Pricing and calculator#
On the pricing page, initPricing() (section 6) wires up:
- Billing switch
#billing-toggle(role="switch",aria-checked). Any element withdata-price-monthlyanddata-price-annualshows one or the other;data-billing-noteelements switch between theirdata-monthlyanddata-annualtext. InPLANS, a plan'spricesandnotesfill those attributes. Annual is the default because the button starts witharia-checked="true". - Calculator with
#calc-seats(1–250),#calc-tokens(steps fromPRICING.tokenSteps, in millions) and thecalc-modelradios. 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.
To change prices, edit PRICING and the matching PLANS and API_PRICES entries in src/data/pricing.ts, and
the starting figures and model radios in the calculator markup of src/pages/pricing.astro. The sliders
announce readable values (aria-valuetext), and the total is announced politely a moment after changes stop.
Code tabs and copy buttons#
On the developers page, each CodeTabs (src/components/CodeTabs.astro) renders a [data-tabs] card with a
role="tablist", tabs carrying data-lang (curl, js or python) and role="tabpanel" panels:
<CodeTabs id="req" label="Language for the request example" samples={REQUEST} />Choosing a language switches every group on the page and is remembered (see Saved data). Arrow keys, Home and End move between tabs.
Copy buttons have data-copy. With a value (data-copy="npm install @prismlabs/sdk") they copy that text;
empty, they copy the visible code block in their [data-tabs] or [data-copy-scope] container. The label
changes to "Copied" and a live region announces it. The samples in src/data/code-samples.ts are HTML,
coloured with spans (t-k, t-s, t-n, t-c, t-f, t-p) styled in global.css; when you change a
sample you can drop the spans and keep plain text, escaping <, > and &. Code blocks are focusable so
keyboard users can scroll them.
Filters#
initFilters() (section 9) powers the changelog topics and the careers team filter. A container
[data-filter="name"] holds buttons with data-filter-value (all plus one per tag); items are
[data-filter-item="name"] with space-separated data-tags. Counts are filled into .seg-count. With
data-filter-param="tag" the choice is kept in the URL (/changelog?tag=security), so filtered views can be
shared. Buttons use aria-pressed, and "Showing 4 releases" is announced (data-filter-noun sets the word).
An optional [data-filter-empty="name"] element shows when nothing matches. The buttons and items are rendered
from TOPICS/RELEASES and ROLE_TEAMS/ROLES.
FAQ accordion#
[data-accordion] on the pricing page contains .acc-item rows built from FAQ; each button has
aria-expanded and aria-controls pointing at its panel. One answer is open at a time; remove the loop over
items in initAccordions() 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 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); everything above a revealed element is revealed too, and nothing is hidden for reduced motion or
without JavaScript (the layout's inline script adds the js class that the hiding depends on). The logo strip
scrolls slowly (paused on hover, static for reduced motion). A back-to-top button appears after one screen of
scrolling; delete src/scripts/back-to-top.ts, its import in the layout and the .back-to-top styles to
remove it.
Forms#
There are two forms, validated by section 10 of src/scripts/main.ts with inline messages, aria-invalid,
aria-describedby and focus on the first problem:
| Form | Where | Checks |
|---|---|---|
data-form="newsletter" |
Footer of every page (SiteFooter.astro) |
Email required and well-formed |
data-form="contact" |
src/pages/contact.astro |
First and last name, work email (data-validate="work-email" rejects FREE_EMAIL domains), company, team size, at least one "Interested in" box (data-group), optional message of at least 20 characters, consent box |
/contact?topic=careers (or any value in CONTACT_TOPICS: sales, trial, enterprise, security, api,
partnerships, other) preselects the topic.
The demo forms don't send anything. On success they show a message that says so. To connect a form
service such as Formspree, replace the successSwap(...) call in initForms() with a
request:
setupValidation(form, () => {
fetch("https://formspree.io/f/your-id", {
method: "POST",
body: new FormData(form),
headers: { Accept: "application/json" },
}).then((r) => {
if (r.ok) successSwap(form, '<p class="text-xl font-medium">Thanks, we’ll be in touch.</p>');
else {
const status = $("[data-form-status]", form);
if (status) status.textContent = "Something went wrong. Please try again.";
}
});
});Every input has a name, so FormData sends all fields. The footer form has no status element; add a
<p data-form-status> if you want an error message there too. The site is static, so there is no server
endpoint to post to; if you add one, use an Astro server adapter or a serverless function on your host.
Saved data (localStorage)#
| Key | Value | Validation |
|---|---|---|
prism-code-lang |
curl, js or python |
Ignored unless it matches one of those three |
It only stores the preferred code language on the developers page. Reading and writing are wrapped in
try/catch, so blocked storage only means the choice isn't remembered. To reset it, run
localStorage.removeItem("prism-code-lang") in the console; to stop saving, remove the
store(LANG_KEY, lang) call in selectLang().
SEO and sharing#
Each page passes title and description to <Layout>; the layout writes the <title>, meta description,
canonical URL, og:* tags, theme-color and twitter:card. Canonical and og:url use the clean URL
(/pricing, not /pricing.html), and og:image is the absolute URL of /img/og-image.png. Both are built
from site in astro.config.mjs, which reads SITE_URL:
SITE_URL=https://prism.example pnpm buildThe 404 page passes noindex, which outputs <meta name="robots" content="noindex"> and no canonical. For a
sitemap, add the official @astrojs/sitemap integration. There is no structured data; Organization and
SoftwareApplication JSON-LD in the layout are good additions.
Accessibility#
Built in:
- Skip link to
#main, landmarks (header,navwith labels,main,footer) and one<h1>per page with ordered headings. - Visible focus rings (
:focus-visiblein brand aqua) and colour 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, live regions for the playground, calculator, filters and copy buttons, and labelled 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 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. 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=/prism pnpm build. It becomes Astro's base, and every link written with url() (including
the link in the sales form's success message) 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#
- The playground answer appears all at once. Your system has "reduce motion" turned on; streaming is intentionally skipped.
- Copy buttons say "Press Ctrl+C". The browser blocked clipboard access. On plain
http://addresses other than localhost the button falls back to an older copy method that some browsers refuse; it works onhttps://and withpnpm dev/pnpm preview. - Calculator totals don't match new prices. Prices are in two places:
PRICING(the calculator) andPLANS/API_PRICES(the cards and table) insrc/data/pricing.ts. Update both. - A
{in page text breaks the build. Astro reads{and}in.astromarkup as expressions. Write{"{"}or move the text intosrc/data/(as the code samples are). - Links or images break when served from a sub-folder. Build with
DEMO_BASE_PATH, and write internal links and asset paths withurl()fromsrc/lib/site.tsrather than plain"/..."strings. /pricingshows a 404 on my host. The host doesn't map extensionless URLs to.htmlfiles. Setbuild.format: "directory"inastro.config.mjsand rebuild.- Social previews show no image. Build with
SITE_URLset to your domain soog:imageis an absolute URL on that domain. pnpm installskips a build script.pnpm-workspace.yamllists the packages allowed to run install scripts (esbuild,sharp,@tailwindcss/oxide); add any new one there.
License#
MIT, as in the repository's license (included as LICENSE.txt in the download). Free for personal and
commercial use. All artwork (logo, wordmarks, avatars, prism illustration and social image) is original SVG and
covered by the same license.