Imported from jonasbergmar/minimal-portfolio (
AGENTS.md). Install upstream withnpx skills add jonasbergmar/minimal-portfolio. Copyright stays with the author.
Agent rules — Astro + Sanity starter
This file is the contract for anyone (human or agent) changing this repo. Follow it exactly. Where it is silent, pick the simplest option that matches existing conventions. Do not add features that are not requested.
Human setup lives in README.md. This file is for how to change the system.
Sanity best practices (mandatory)
Before changing schema, GROQ, TypeGen, Presentation, Visual Editing, images, Portable Text, Studio structure, or the Astro integration: load the matching Sanity skill rules (astro, visual-editing, schema, groq, typegen, page-builder, project-structure, …) and verify against current docs for the installed package versions. Prefer official @sanity/astro / Studio APIs over custom workarounds. Do not invent env vars, loaders, or stega handling that contradict those guides.
Structure note: Sanity prefers a standalone Studio in studio/ (TypeGen, CLI, optional sanity deploy). This template also embeds Studio at /admin via studioBasePath because that is a product requirement — keep schema/CLI in studio/, re-export config into web/, and do not move the source of truth into the Astro app.
Goal
A cloneable template. After clone: create a Sanity project, copy .env.example → .env, npm i, allow CORS for http://localhost:4321, npm run seed, then npm run dev. That yields the Astro site on http://localhost:4321, Studio on http://localhost:4321/admin, sample content, visual editing (Presentation + overlays), and a hidden /styleguide brandbook. Human setup details live in README.md; sample documents live in studio/seed/starter.ndjson.
Stack
- Astro 5 —
output: 'static'. Drafts render on demand inastro dev. - Sanity Studio v4 — schema and CLI live in
studio/. Embedded in Astro at/adminvia@sanity/astro(studioBasePath). UsestudioRouterHistory: 'browser'so Presentation intent links (/admin/intent/edit/…) resolve; hash history only serves/adminand 404s those paths. Dev SPA fallback rewrites/admin/*→/admin; static hosts need the same rewrite in production.web/sanity.config.tsre-exportsstudio/sanity.config.ts. Optional standalone:npm run dev -w studio(port 3333) ornpm run deploy -w studio. - Tailwind CSS v4 via
@tailwindcss/vite. CSS-first. Notailwind.config.js. Theme values come from CSS variables mapped through@theme. - Lenis + GSAP (ScrollTrigger) for scroll; CSS for hover/focus/accordion.
- TypeScript everywhere, strict.
- Prettier with
prettier-plugin-astroandprettier-plugin-tailwindcss. - npm workspaces (
web,studio). One rootnpm i.
Layout
├── package.json # workspaces: ["web", "studio"]; scripts: dev, build, typegen, format
├── .env.example # single env file at root, shared by both apps
├── AGENTS.md
├── README.md
├── studio/
│ ├── sanity.config.ts # Studio config (schema, structure, Presentation)
│ ├── sanity.cli.ts # TypeGen: scan ../web/src, emit ../web/src/sanity.types.ts
│ └── src/
│ ├── schemaTypes/
│ │ ├── documents/ # page, homePage, faq, siteSettings
│ │ ├── objects/ # seo, link, blockContent, pageBuilder
│ │ ├── blocks/ # hero, features, splitImage, faqs, callToAction
│ │ └── index.ts
│ ├── structure/ # desk structure, singletons
│ └── presentation/ # document location resolvers
└── web/
├── astro.config.mjs # envDir = repo root; sanity (studioBasePath /admin) + react + tailwind
├── sanity.config.ts # re-exports ../studio/sanity.config.ts for the embedded Studio
└── src/
├── styles/
│ ├── tokens.css # ALL design variables live here
│ └── global.css # fluid rem + reset + tailwind + @theme + base + shared classes
├── lib/ # load-query, queries, image, links, animations, lenis
├── layouts/Layout.astro
├── components/
│ ├── blocks/ # full-width page-builder sections only
│ ├── layout/ # site chrome
│ └── ui/ # small reusable primitives
└── pages/ # index.astro, [...slug].astro, styleguide.astro
Never mix component folders. blocks/ = page-builder sections. layout/ = navbar/footer/skip link. ui/ = Button, Eyebrow, Heading, SanityImage, RichText.
Environment
Root .env only. Astro reads it via envDir + Vite loadEnv pointed at the repo root. Studio CLI (sanity.cli.ts) loads the same file with dotenv and injects PUBLIC_ / SANITY_STUDIO_ into the standalone Vite config. Embedded Studio gets the same values through astro.config.mjs vite.define.
Required keys (see .env.example):
PUBLIC_SANITY_PROJECT_IDPUBLIC_SANITY_DATASETPUBLIC_SANITY_VISUAL_EDITING_ENABLEDPUBLIC_SANITY_STUDIO_URLSANITY_API_READ_TOKENSANITY_STUDIO_PREVIEW_ORIGIN
Do not invent extra env vars unless a task requires them.
Sizing — rem only, fluid root
Every size is rem (font, spacing, widths, radii). Exceptions: breakpoints in media queries may be px; borders may be 1px hairlines. Nothing else in px.
The fluid root snippet at the top of web/src/styles/global.css is sacred — do not retune, replace, or wrap it in clamp(). Tokens stay plain rem because the root scales. Body text ≈ 1.6rem (--text-md). Repeated values become tokens. No magic numbers in components.
global.css order: (1) fluid root snippet, (2) Andy Bell–style reset including prefers-reduced-motion, (3) @import "tailwindcss", (4) @theme inline { … } mapping tokens, (5) base elements, (6) .container, .eyebrow, .prose.
Design tokens
web/src/styles/tokens.css is the only place to retune look and feel. Map tokens into Tailwind in @theme inline so bg-accent, text-ink-muted, rounded-md, p-8, font-display resolve to the same variables. Components may use utilities or var(--…).
Do not add styles on /styleguide (or anywhere) that are not derived from tokens.
Animation
- Init Lenis once in
Layoutvialib/lenis.ts. Sync:lenis.on('scroll', ScrollTrigger.update); drive Lenis fromgsap.tickerwithlagSmoothing(0). - Reveals are declarative in
lib/animations.ts:data-animate="fade-up" | "fade-in";data-animate="stagger"+ childrendata-animate-child. Blocks opt in — no per-component GSAP except special cases (styleguide cover / replay). - CSS handles hover, focus, accordion using motion tokens.
- Reduced motion: do not init Lenis;
gsap.setfinal state. - No-JS / a11y: content must be visible before JS. Animate from a hidden state applied by JS. Never hide content in CSS pending animation.
Data layer & visual editing
- Fetch only through
loadQuery(web/src/lib/load-query.ts), includinggetStaticPaths. - When
PUBLIC_SANITY_VISUAL_EDITING_ENABLED === 'true': perspectivedrafts,stega: true,resultSourceMap: 'withKeyArraySelector', token required (throw if missing),useCdn: false. Otherwise:published, no stega. Layout.astrorenders<VisualEditing />from@sanity/astro/visual-editinggated by the flag.- Queries:
defineQueryfromgroqinlib/queries.ts. Shared fragments for images, links, SEO, and page-builder. Always project_keyand_typeon arrays. Expand FAQ refs withfaqs[]->. - TypeGen:
studio/sanity.cli.tsscans../web/src/**, emits../web/src/sanity.types.ts. After schema or query changes:npm run typegen. Type blocks withExtract<…, { _type: 'hero' }>from query result types. - Queries used by
getStaticPathsmust be imported fromlib/(Astro hoistsgetStaticPaths; module-scope consts in the same.astrofile are not visible inside it). TypeGen does not reliably parse.astrofrontmatter — keep queries in.ts.
Stega golden rule
| Use | Clean? |
|---|---|
Comparisons, class switching, object keys, hrefs, IDs |
Yes — stegaClean from @sanity/client/stega |
<title>, <meta>, anything in <head> |
Yes |
| Visible body text, Portable Text, image alts rendered on the page | No — cleaning breaks click-to-edit |
Sanity content model
- Always
defineType/defineField/defineArrayMember. - Icons from
@sanity/iconson every document/object (root named exports in Studio v4). - Model data, not presentation: no color, fontSize, or heading-level fields. Heading levels are decided in the frontend (
Headingasprop). - Every block
preview: title, block name as subtitle, image/icon media. - Image
altis required on schema images. - Documents:
page(title, slug,contentpageBuilder, seo),homePagesingleton,privacyPolicysingleton (title, lastUpdated, blockContent body, seo →/privacy),article(title, slug, excerpt, publishedAt, image, body, seo →/articles/[slug]),caseStudy(title, slug, client, summary, publishedAt, image, body, seo →/case-studies/[slug]),faq(question, blockContent answer),testimonial(quote, name, role, company, image),siteSettingssingleton (siteTitle, nav links, footerText). - Objects:
seo,link(label + internal/external radio; internal refs: page, homePage, privacyPolicy, article, caseStudy),blockContent(normal/h2/h3/quote, bullet/number, strong/em/code, link annotation, captioned image),pageBuilder(blocks, insertMenu grid →/static/block-previews/<type>.png). - Blocks:
hero,features,splitImage,faqs(heading + FAQ refs),testimonials(heading + testimonial refs),callToAction. - Structure: singletons pinned via
S.document().documentId(...)with IDshomePage/siteSettings/privacyPolicy. Then Pages, Articles, Case studies, Testimonials, FAQs; remaining types filtered. - Presentation:
previewUrl.originfromSANITY_STUDIO_PREVIEW_ORIGIN(site origin). Locations: homePage →/, page →/${slug}, privacyPolicy →/privacy, article →/articles/${slug}, caseStudy →/case-studies/${slug}. - Reserved slug: skip
admin,privacy,articles,case-studies,styleguidein pagegetStaticPaths. Never let[...slug]own/admin/*. - Edit intents: Visual Editing opens
/admin/intent/edit/mode=presentation;id=…;type=…;path=…. That requires browser history +/admin/*→ Studio SPA rewrite.
Adding a block (4 steps, this order)
- Schema in
studio/src/schemaTypes/blocks/— icon + preview, register inindex.tsandpageBuilder. - Query fragment in
web/src/lib/queries.ts—_key,_type, expand refs if shared. - Component in
web/src/components/blocks/—Extract<…, { _type: '…' }>. - Registry in
PageBuilder.astro. Unknown types: dev warning only, nothing in production.
Then npm run typegen.
Components
- Button —
<a>ifhrefis present, else<button type="button">. Variantsprimary|secondary, sizessm|md. Externalhttp(s)to another origin:rel="noopener noreferrer". Disabled links: inert<span aria-disabled="true">. Disabled buttons: nativedisabled. Focus ring and hover from tokens. - Navbar — title + links from Site Settings. Real
<button>toggle witharia-expanded/aria-controls. Escape closes and returns focus. Current page:aria-current="page". Sticky, height--navbar-height. - Footer — nav + footer text + copyright year.
- SkipLink — first focusable, jumps to
#main. - Heading —
asfor semantic level. Never store heading level in Sanity. - SanityImage —
@sanity/image-url, alt from schema, width/height, lazy by default. - RichText —
astro-portabletextwrapped in.prose. - PageBuilder —
_type→ component from one registry. Always use_key(never array index as a key).
Accessibility (non-negotiable)
Before building or changing any UI (web/src/components, layouts, pages, styles that affect interaction): load the web-design-guidelines skill and apply its accessibility and focus rules as mandatory best practice. Fetch the latest checklist from https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/main/command.md when reviewing or shipping UI — do not rely on memory. Prefer semantic HTML over ARIA; never ship icon-only controls without aria-label, interactive elements without keyboard support / visible :focus-visible, or motion that ignores prefers-reduced-motion.
Repo baseline (always keep): semantic landmarks (header, nav, main id="main", footer); exactly one h1 per page; required image alts; :focus-visible from tokens on interactives; FAQ accordion as <details>/<summary>; color tokens meet WCAG AA; keyboard-test navbar and FAQ; honor prefers-reduced-motion globally.
/styleguide
Hidden brandbook: <meta name="robots" content="noindex">, linked from nowhere, not in any sitemap. Cover + numbered chapters 01–07 (foundations, color, type, spacing, motion, components, blocks). Sticky chapter nav + scroll-spy on desktop. One h1 on the cover. Mono token labels. Specimen cards from surface/line/radius tokens. Replayable GSAP demos. Built entirely from tokens and existing components.
Prettier & checks
Root .prettierrc: no semicolons, single quotes, both plugins. npm run format. Ignore generated files (sanity.types.ts, dist, .astro). After web or studio changes: npm run check -w web and npx tsc --noEmit in studio. Fix until clean.
Do not
- Hide content in CSS pending animation.
- Store presentation in Sanity (colors, type sizes, heading levels).
- Put stega strings in
<head>or use them as hrefs/classes withoutstegaClean. - Use array index as a list key — use
_key. - Add
tailwind.config.js. - Mix
blocks/,layout/, andui/. - Init Lenis or run reveals when
prefers-reduced-motion: reduce. - Change the fluid root font-size snippet.
- Retune the design system anywhere except
tokens.css. - Fetch Sanity except through
loadQuery. - Add a sitemap that includes
/styleguide. - Commit
.envor tokens. - Skip typegen after schema/query changes.
- Use
sanity schema extract/ Studio APIs from memory — verify against installed package versions (@sanity/iconsexport style, TypeGen CLI, Tailwind v4@theme).
Deployed draft preview
Production stays static, flag false. Hosted preview: SSR adapter, output: 'server', PUBLIC_SANITY_VISUAL_EDITING_ENABLED=true on preview only, CORS + SANITY_STUDIO_PREVIEW_ORIGIN for that origin.