Marketing website for Assembly Studio (the AI workflow platform).
Never run vercel --prod. It publishes the working directory straight to
https://studio.assembly.com, skipping GitHub and any review.
main is staging, not production. Merging a PR into main deploys to
https://studio.assembly-staging.com. Production is the production branch, and
publishing a GitHub Release is what promotes main onto it and changes
https://studio.assembly.com.
Ask before promoting. Merging into main is safe and expected; publishing a
release is the deploy.
Full detail in "Branching and deploying" below.
- Framework: Next.js 16 (App Router)
- Styling: Tailwind CSS v4
- Language: TypeScript
- Deployment: Vercel
src/
app/ # Pages (file-based routing)
page.tsx # Homepage
customers/page.tsx # Customer case studies
templates/page.tsx # Template gallery
templates/[slug]/ # Template detail pages
security/page.tsx # Security page
pricing/page.tsx # Pricing page
components/
layout/ # Header, Footer (shared across all pages)
home/ # Homepage section components
ui/ # Reusable UI primitives
lib/
constants.ts # Nav links, URLs, site name
templates.ts # Template data and types
- Use only font-weight 400 (regular) and 500 (medium). Never use bold (600/700).
- Keep heading sizes restrained — prefer text-3xl/text-4xl, max text-5xl for page titles.
- Use CSS variables from globals.css for colors (--accent, --muted, --border, etc).
- Keep components responsive — mobile-first, max-w-7xl container.
Light and dark are separate value sets — editing one must never change the other. Most regressions on this site come from tuning dark mode and having it bleed into light (or vice versa), because a shared color got hardcoded in a spot that both themes render.
Rules:
- Only ever touch color values inside a theme-scoped block. Light values live
in
:root/ the light branch; dark values live under[data-theme="dark"],.v72-mock-dark, or the dark branch of an inlinetheme === "dark" ? {…} : {…}object (e.g. the--v69-*overrides inhero-v76.tsx). If you change a number, confirm which theme's block you're in and leave the other alone. - Components must read tokens, never hardcode a themed color. Use
bg-[var(--v69-inner)],text-[var(--v69-ink)],text-muted-foreground, etc. A raw hex or a Tailwind gray (bg-neutral-200,text-neutral-800) inside a themed surface is a regression waiting to happen — it can't flip with the theme. - The
--v69-*mock tokens (--card,--inner,--well,--well-2,--chip,--ink) are the shared palette for the template-card mocks. Card inner panels/bubbles/pills use--v69-inner(the light-gray "lift" tone); keep every card in the family on the same token so they stay consistent. - After changing any color, verify both themes before considering it done.
If an element already has an established pattern on this site, reuse that exact pattern. Do not invent a new variation unless explicitly asked to. Consistency across the site is a hard requirement — a new one-off style for something we already solved (filters, tags, cards, buttons, toggles, etc.) is a bug, not a feature.
Before building any UI element, check whether it already exists elsewhere and match it. Known shared patterns:
- Filter chips — mono, uppercase,
rounded-md,bg-muted(inactive) /bg-foreground/10(active). Seetemplates-browser.tsxandcustomers-hub.tsx. - Tags / stat chips — mono, uppercase,
rounded-md bg-muted px-3 py-1.5, value intext-foreground+ label intext-muted-foreground. See the case-study detail page and the customers review strip. - Segmented toggle — sliding thumb, matches the pricing billing toggle. See
pricing-plans.tsxandproduction-gap.tsx. - Primary/secondary buttons, cards, section spacing — reuse the existing component/classes rather than restyling per page.
When a genuinely new element is needed, prefer extracting a shared component so the next page reuses it too.
Vercel builds every push. Which URL the build lands on depends on the branch:
| Branch | Environment | URL |
|---|---|---|
| any branch | Preview | a .vercel.app URL, one per PR |
main |
Staging | https://studio.assembly-staging.com |
production |
Production | https://studio.assembly.com |
Work only ever flows one way: a feature branch opens a PR into main, and
main is fast-forwarded into production once staging looks right. Nothing is
merged back down.
Staging sits in front of main rather than behind it, and that is the whole point. The staging branch this replaced sat behind main, drifted from it, and was deleted for carrying nothing main lacked. In front, it cannot drift: the promote is always a fast-forward of main, so staging is by definition what main already is.
- Everyone opens a PR into
main. The branch rule requires one approving review from the code owner, so a teammate's approval alone will not land it. Approvals are dismissed on new pushes, and the newest push must be approved. - Ana pushes to
maindirectly. She owns the site and is its code owner. - Feature branches branch off
mainand open PRs back intomain. - Promoting is publishing a GitHub Release. See "Releasing" below. Nobody
pushes
productionby hand — the branch rule won't let them, and the release is what leaves a record of what shipped.
Production is deployed by publishing a release, not by pushing a branch.
.github/workflows/release.yml runs on release: published, checks that the
tagged commit is an ancestor of main, and pushes it to production. Vercel
builds that push, and the site is live a couple of minutes later.
So the whole promote is: GitHub → Releases → Draft a new release, tag a
commit on main, write what changed, publish.
- The ancestry check is the point. A tag can sit on any commit, including
one that never opened a PR and never rendered on staging. The workflow refuses
those, so production only ever moves to something
mainalready holds. If it fails, merge tomainfirst and re-tag — don't work around it. - It needs
RELEASE_TOKEN, a repository secret holding a token that can push toproduction. The built-inGITHUB_TOKENcannot: a code-owner branch rule is not bypassable by permissions, only by identity, so the token has to belong to someone on that rule's bypass list. A missing secret fails the first step with an explanation rather than a 403 at the end. The token also needs permission to write workflow files (classic PAT:workflowscope; fine-grained: Workflows) —productioncarries.github/workflows/like any other branch, and a token without it is refused on that ground alone, with an error that says nothing about branch rules. - The push is deliberately not forced. Production only fast-forwards, so a
rejected push means production holds a commit
maindoesn't — investigate it, don't force past it. - To undo a bad release, use Vercel's instant rollback, not a revert commit
and not a force push. It re-aliases the previous production build without
rebuilding, so it's live in seconds. Fix forward on
mainafterwards.
Vercel's Promote button and vercel promote re-point the production alias at a
build that already exists; they do not rebuild it. Don't use them here.
next.config.ts reads VERCEL_ENV at build time and bakes
X-Robots-Tag: noindex, nofollow into every non-production build, so promoting
the staging artifact would deindex the live marketing site. SITE_URL is
resolved at build time too. Promote by publishing a release and letting
production build itself — a couple of minutes, and the env-gated pieces come out
right.
The release workflow gates the GitHub path: production only takes commits that
reached main, and only a bypass-list token can move the branch. It does not
reach the Vercel CLI. vercel --prod still uploads a working directory
straight to the live site, past the branch rule, the release, and the ancestry
check alike. That one stays a shared habit rather than a gate, so it's worth a
beat of care: know that what you're about to do is going live.
If you're not sure it should go out yet, leave it on main. Staging is a real
URL on a real browser, which is a better review than a description — and there
is no cost to letting it sit there.
That command uploads whatever is in the working directory straight to production. It skips GitHub entirely: no branch rule, no review, no code owner. Production has been overwritten from an unpushed feature branch this way, so the live site ran code that existed on one laptop and nowhere else.
To see a change on a real URL, do one of:
vercelwith no flags, which builds a preview deployment,- push the branch, which gets Vercel to build a preview automatically, or
- merge it into
main, which puts it on staging.
Preview and staging URLs sit behind Vercel SSO, so teammates can open them and
the public cannot. Staging also serves X-Robots-Tag: noindex, nofollow, so it
can't compete with production in search even if the SSO gate is ever lifted.
npm run dev plus a preview pane covers most work, with one trap worth knowing:
the pane runs with document.visibilityState === "hidden", so
IntersectionObserver never fires, requestAnimationFrame is paused, and video
never starts. Anything gated on scrolling into view or on autoplay cannot be
tested there and will look broken when it is fine. Use a preview URL in a real
browser, or drive headless Chrome over CDP, where the page is genuinely visible.
npm run dev— start dev servernpm run build— production buildnpm run lint— run ESLint
All of it runs off src/lib/seo.ts and src/lib/og.tsx.
- Never write a bare
export const metadata = { title, description }. Next inherits the parent'sopenGraphobject wholesale, so a page that sets only those two still ships the homepage's social card. Always go throughpageMetadata(), which writes title, description, canonical, Open Graph, and Twitter together. - Static page copy lives in
PAGE_SEOinsrc/lib/seo.ts— one record per page, read by the page metadata, the social card, and the sitemap. - One social card for the whole site —
public/og.jpg, exported asOG_IMAGEfromsrc/lib/seo.ts. Per-page generated cards were tried and rejected; don't reintroduceopengraph-image.tsxroute files. 1200×630 is the only size worth shipping, since every platform crops its own thumbnail from it. - Because
pageMetadata()writes a wholeopenGraphobject, it has to includeimages— a page that omits it ends up with no card image at all. - The sitemap walks
src/appat build time, so a new page appears without anyone remembering. To keep a route out, add it toEXCLUDEDinsrc/app/sitemap.tswith a reason, and give the pagerobots: { index: false }so the two can't disagree.
SITE_URL in src/lib/constants.ts is the single source for it, and everything
indexable is built from it: canonicals, og:url, og:image, the JSON-LD URLs,
every sitemap <loc>, the Sitemap: line in robots.txt, and the origin check
in /api/shorten. Change it in one place or not at all — a host that only
mostly matches is worse than either host, because the sitemap and the canonical
start naming different sites.
It used to name studio.assembly.com, which now only redirects. Everything the
crawler read pointed a hop away from the page it was already on, and
/api/shorten rejected proposals whose origin no longer matched.
The studio.assembly.com redirect is not in this repo. It's a Vercel domain
redirect on the project (Settings → Domains), path-preserving, 301. Grepping
next.config.ts for it finds nothing, so look there before concluding it doesn't
exist. It's 301 and not 307 deliberately: a temporary redirect tells Google
the move might be reverted, so the old host keeps its ranking signals instead of
passing them on.
studio.assembly-staging.com still redirects 307 to the staging apex. Left
temporary on purpose — a 301 is cached hard by the browser, and a stale one on
a host we may want to serve from again is a bad trade for a host no crawler is
allowed to index anyway.
www.assembly.com redirects to the apex the same way, also 301 and
path-preserving. It used to serve the site outright, which put the whole thing on
two hosts at once; the canonicals named the apex, so the duplicate was at least
declared, but a redirect settles it rather than describing it.
Every marketing family except the templates gallery used to be read live from Contentful. It no longer is. The copy and the imagery were taken out of the CMS and committed:
| Pages | Frozen in |
|---|---|
Feature pages (/client-portal, /invoicing, …) |
src/lib/features.frozen.ts |
/solutions/* |
src/lib/solutions.frozen.ts |
/comparison and /comparison/* |
src/lib/comparison-index.frozen.ts, src/lib/comparisons.frozen.ts |
/definitions/* |
src/lib/definitions.frozen.ts |
/jobs copy and role descriptions |
src/lib/careers-page.frozen.ts, src/lib/job-listings.frozen.ts |
/about team |
src/lib/team.frozen.ts |
So a copy change is an edit to a .frozen.ts file and a deploy. Editing the
Contentful entry does nothing — nothing reads it.
- Imagery lives in
public/images/cms, pulled down through Contentful's image API at 2400px webp. The CDN is out of the loop too, so an asset being replaced or unpublished in the CMS can't change or break a page. scripts/freeze-cms.mts(npm run freeze-cms) generated these files. It is a one-time tool kept for reference. Re-running it overwrites hand edits with whatever the CMS still holds, which by now is the older copy — so don't, unless you are deliberately re-taking a family.- Two live sources remain, both on purpose.
src/lib/contentful.tsserves the templates gallery, because marketing publishing a template should not need a deploy; andsrc/lib/careers.tsreads the Ashby job board, because a closed role has to stop listing.src/lib/ghost.ts(the blog) was never Contentful.
The changelog is committed to the repo. It used to be a Ghost instance of its
own (copilot-updates.ghost.io), read at request time; it is now 170 Markdown
files in src/content/updates with their imagery in public/images/updates.
Publishing in that Ghost does nothing — nothing reads it.
Adding an entry is adding one file: src/content/updates/<slug>.md, where
the slug becomes its URL at /updates/<slug>.
---
title: "Bulk actions in the Files app"
excerpt: "One sentence for the search result and the page description."
date: 2026-09-02T10:00:00.000-04:00
updated: 2026-09-02T10:00:00.000-04:00
---
## Bulk actions in the Files app
Open on the headline as an `h2` — it is the entry's name on the listing and its
`h1` on its own page, so `title` should be the same words. Then ordinary
Markdown: paragraphs, `###` for sections, `-` lists, `[links](https://…)`.
Nothing lists the entries, so no index needs editing and the sitemap picks the new page up on its own. Notes:
- Screenshots go under
public/images/updates/<year>/<month>/, webp, no wider than 1600px — entries render inside a 720px measure. Their dimensions are read off the file, so there is nothing to measure or declare. dateorders the listing and stamps the entry. The filename does not.- A YouTube embed or a video is written as the HTML it is; Markdown passes a block of it through. Copy the shape from an existing entry.
scripts/freeze-updates.mtsis the one-time importer that brought the archive across, kept for reference likefreeze-cms.mts. Re-running it overwrites hand edits with whatever the old Ghost still holds — so don't.
- Create
src/app/<page-name>/page.tsx - Add an entry to
PAGE_SEOinsrc/lib/seo.tsand exportmetadata = pageMetadata(PAGE_SEO.<key>)from the page - Add the route to
NAV_LINKSinsrc/lib/constants.tsif it belongs in the nav - Use the
Sectioncomponent fromsrc/components/ui/section.tsxfor consistent spacing
The sitemap picks the page up on its own — no edit needed.
Add an entry to the BASE_TEMPLATES array in src/lib/templates.ts. The detail
page and its sitemap entry are both generated from that one entry.
Once Contentful is wired up (see ENV.example.md) it becomes the source instead:
prebuild runs scripts/contentful/pull.mjs, which writes
src/lib/templates.generated.ts, and TEMPLATES prefers it over the committed
array. Notes for anyone touching that path:
- Codegen, not a runtime fetch.
TEMPLATESis read at module scope by client components (the hero strip, the proposal tools), where anawaitcan't reach. Making it async would mean refactoring all of them, so the data is baked in at build time instead. It also keeps the site static and immune to a CMS outage. - The pull never fails the build. No credentials, or an unreachable
Contentful, writes
nulland the committed templates are used. templates.generated.tsis machine-written — don't hand-edit it.- The space is shared with another site, so everything is namespaced to the
studioTemplatecontent type andcontentful:setuprefuses to modify a type it doesn't recognise as ours.
Add an entry to CASE_STUDIES in src/lib/case-studies.ts. seoDescription is
required — it's the search snippet, and it's a different job from summary,
which is written for the index card.