Freeframe

Documentation

Prism

v1.0.0Updated Oct 12, 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 (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).

sh
pnpm install
pnpm dev          # http://localhost:4321

With 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.md

Customising#

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

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

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

ts
{
  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 in public/img/favicon.svg.
  • Customer logos — inline SVG wordmarks in the logo strip of src/pages/index.astro. Each has role="img" and an aria-label; keep those when you replace them.
  • Avatars — src/components/Avatar.astro draws each portrait from hues and a hair path 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 in public/img/ and link them with url("/img/name.jpg"), with a real alt.
  • 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 in global.css. Remove grain from <body> in the layout to drop the grain.
  • Social image — public/img/og-image.png (1200×630) is what the pages reference. Edit public/img/og-image.svg and export it to PNG at 1200×630; most social sites don't display SVG.

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 of src/scripts/main.ts; data in src/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 via msPerToken.
  • 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's step() logic, or append text to #pg-output directly.
  • 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 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 with data-price-monthly and data-price-annual shows one or the other; data-billing-note elements switch between their data-monthly and data-annual text. In PLANS, a plan's prices and notes fill those attributes. Annual is the default because the button starts with aria-checked="true".
  • Calculator with #calc-seats (1–250), #calc-tokens (steps from PRICING.tokenSteps, in millions) and the calc-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.

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:

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

sh
SITE_URL=https://prism.example pnpm build

The 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, nav with labels, main, footer) and one <h1> per page with ordered headings.
  • Visible focus rings (:focus-visible in brand aqua) and colour 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, 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#

sh
pnpm build        # static site in dist/
pnpm preview      # preview dist/ locally

dist/ 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 directory dist.
  • Vercel: import the repository; it detects Astro and static output needs no adapter. Add a vercel.json with { "cleanUrls": true } so /pricing serves pricing.html.
  • GitHub Pages: use the official Astro GitHub Action. For a project site (username.github.io/repo), build with DEMO_BASE_PATH=/repo.
  • Any static host: upload the contents of dist/. If the host doesn't map /pricing to pricing.html, change build.format to "directory" in astro.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 on https:// and with pnpm dev/pnpm preview.
  • Calculator totals don't match new prices. Prices are in two places: PRICING (the calculator) and PLANS/API_PRICES (the cards and table) in src/data/pricing.ts. Update both.
  • A { in page text breaks the build. Astro reads { and } in .astro markup as expressions. Write {"{"} or move the text into src/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 with url() from src/lib/site.ts rather than plain "/..." strings.
  • /pricing shows a 404 on my host. The host doesn't map extensionless URLs to .html files. Set build.format: "directory" in astro.config.mjs and rebuild.
  • Social previews show no image. Build with SITE_URL set to your domain so og:image is an absolute URL on that domain.
  • pnpm install skips a build script. pnpm-workspace.yaml lists 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.

Search documentation

Search the guides and every template's documentation