The website for Draftlab — a creative project design studio for social good. Built on the Scaffold template (Astro 6 + Tailwind CSS v4 + React 19), deployed to Netlify.
This README is the onboarding doc — it explains what's where and why so a new contributor can get productive quickly. Deeper notes for AI-assisted work live in .localconfig/CLAUDE.md.
git clone https://github.com/draftlab-org/draftlab.org.git
cd draftlab.org
npm install
npm run devDev server at http://localhost:4321. Drafts (status draft) are visible in dev and in any preview build with PUBLIC_PREVIEW=true; production hides them.
npm run dev # Dev server (drafts visible)
npm run build # Production build (drafts hidden)
npm run preview # Preview the production build locallyThere's no test runner. Biome is configured (biome.json) but isn't wired to an npm script — run npx biome check . if you want lint/import-organisation feedback.
Almost everything on the site is organised around a two-axis framework. Internalising it makes the content schemas and component names obvious.
- Phases (project timeline):
understand→define→deliver→sustain - Modalities (engagement style):
clinic◎,studio◈,community◉
These slugs are load-bearing. They appear as Zod enums in src/content.config.ts, as CSS custom properties (--color-phase-{slug}-{light|dark}), and as Tailwind classes (tag-{slug}). If you rename one, search the whole codebase. There's a historical rename worth knowing about: build → deliver and council → community (April 2026) — old branches and notes may still reference the old names.
| Concern | Choice |
|---|---|
| SSG / framework | Astro 6 with the Netlify adapter |
| UI framework (interactive islands) | React 19 |
| Styling | Tailwind CSS v4 (@theme block in CSS, no tailwind.config.*) |
| Content | Astro content collections + Zod schemas |
| CMS | Pages CMS (config in .pages.yml) |
| Forms | Brevo (transactional email) + Altcha (proof-of-work captcha) |
| Search | Fuse.js (in-browser fuzzy search) |
| Markdown | MDX, expressive-code, remark-gfm, rehype-slug, custom rehype plugins |
| Icons | unplugin-icons + Iconify |
All content lives in src/content/ and is validated by Zod schemas in src/content.config.ts. Adding a new collection means defining it there, then optionally filtering by status with isVisible() from @utils/content.
| Collection | Format | What it is |
|---|---|---|
pages |
YAML | One file per route. Carries a sections array — see "Page building". home, about, projects, get-in-touch live here today. |
projects |
YAML + markdown body | Case studies. Reference phases[], modalities[], skills[] (by slug). Optional updates[] array for the per-project timeline. |
people |
JSON | Team profiles. Carry headshot, social links, optional skills[]. |
phases |
JSON | The four timeline phases. |
modalities |
JSON | The three engagement modalities. |
skills |
JSON | Skill cards with name, description, and an Iconify icon id (e.g. "hugeicons:test-tube-01"). Projects and people reference these by slug. |
organisations |
JSON | Clients, partners, and funders. type is one of `client |
quotes |
JSON | Testimonial pull-quotes. References organisations and (optionally) projects. |
navigation |
JSON | Header / footer / mobile menus. Items can be internal (pageRef) or external (url), and can nest into dropdowns. |
site |
JSON | Single config.json with global title, description, social links, OG images, optional cookie-consent + archived-banner config. |
Most collections share a unified status field with three values:
draft— only visible in dev (npm run dev) orPUBLIC_PREVIEW=truepreviewspublished— visible everywherearchived— visible everywhere; pages with this status surface the archived banner if configured
Use isVisible(entry) from @utils/content to filter, or the per-collection helpers in @utils/{pages,projects,people,...} which already apply it.
projects carries a second, orthogonal contentStatus field (placeholder | polish | ready) tracking internal editorial readiness. It's informational — no rendering logic gates on it today.
Images are referenced by absolute path from the project root (e.g. /src/assets/projects/foo.png). The image() helper in collection schemas resolves and optimises them at build time.
Pages are assembled from a typed list of section blocks. The dynamic route src/pages/[...path].astro matches every page entry except home, which has its own src/pages/index.astro. There are also two hand-rolled detail routes: src/pages/projects/[slug].astro and src/pages/people/[id].astro.
Each page YAML looks like:
title: Home
status: published
sections:
- type: skillsCloud
background:
bgColor: phase-gradient
bgType: full
- type: callout
content: Draftlab is a design and technology studio…
- type: projectsRoll
title: Featured projects
featuredOnly: true
limit: 4Section types are a discriminated union in src/content.config.ts. Adding a new one is a three-step change:
- Add a
z.literal('newType')branch to thesectionsSchemadiscriminated union. - Create
src/components/sections/NewTypeSection.astro(or.tsx). - Add a
case 'newType'to the switch insrc/components/sections/Sections.astro.
If it should also be editable in Pages CMS, add a matching block under components: in .pages.yml.
hero, richText, callout, button, card, flexi (nested sections), people, feedGrid, projectsRoll, latestUpdates, howWeWork, phasesOverview, modalitiesOverview, skills, skillsCloud, organisationsRoll, calEmbed, applyForUxd, uxdCta.
Vertical rhythm is centralised in src/layouts/SectionLayout.astro via three tiers (narrow, normal, wide); Sections.astro maps each section type to a tier. Don't sprinkle py-* utilities on individual section components — tune the tier instead.
src/components/ follows atoms → molecules → organisms → sections.
- atoms — primitives like
Button,Heading,Text,Tag,Eyebrow,SectionTitle,SkillIcon,ModalityIcon,GradientLink,DevOnly. - molecules — small compositions:
Card,ProjectCard,QuoteCard,NavItem,NavItemDropdown,FormField,FilterBar,FilterDropdown,SocialLinks,PhaseTag,Accordion,UxdApplicationForm. - organisms — standalone complex components:
Head,Header(insections/),Footer,Navigation,MobileMenu,Hero,RichSearch,SkillsCloud,FeedFilter,FilterableContent,CookieBanner,Person,TOC. - sections — full-width page blocks (see list above). These are what page YAML composes.
DevOnly and devClass() (from @utils/dev) hide UI in production — useful for scaffolding labels and debug borders. Sections.astro already labels every section in dev so you can see structure at a glance.
Tailwind v4's @theme block lives in src/styles/colors.css. The site identity centres on a four-stop phase gradient (understand → define → deliver → sustain). Every gradient utility on the site — nav bottom bar, hero band, full-bleed section backgrounds, dividers (gradient-rule), input underlines (gradient-underline), card frames (gradient-frame), and per-project card backgrounds — pulls from a single source of truth:
--phase-gradient-method: in oklch longer hue;
--phase-gradient-angle: 100deg;
--phase-gradient-stops: var(--color-phase-understand), …, var(--color-phase-sustain);
--phase-gradient-stops-light: /* light tints */
--phase-gradient-stops-dark: /* dark tints */Change the method or angle once and every gradient updates. The vertical-rule utility (gradient-rule-vertical) keeps to bottom since tilting a 2px-wide line doesn't read.
Useful interpolation methods: in oklch longer hue (vibrant rainbow), in oklch shorter hue (perceptually uniform, no grey midpoints), in oklab (CSS Color 4 default — passes through grey on complements). Edit the value in src/styles/colors.css and hard-refresh to preview.
--font-serifand--font-sansboth map to Fraunces (variable; weights 300/400/500/700)--font-monomaps to JetBrains Mono
Fonts are configured in astro.config.mjs via Astro's font integration (Bunny Fonts provider) and declared in src/components/organisms/Head.astro. Fraunces 400 + 700 (latin) are preloaded for above-the-fold paint; everything else loads on demand.
src/styles/global.css— base importssrc/styles/typography.css— heading/body/link defaultssrc/styles/components.css— button, card, tag variantssrc/styles/utilities.css— site-specific utilities (brackets, corner brackets, gradient utilities)src/styles/breakpoints.css— responsive breakpoint overrides
unplugin-icons is configured in astro.config.mjs for JSX/React output (compiler: 'jsx'). Use class (not className) in both Astro and React components.
import IconGithub from '~icons/simple-icons/github';
<IconGithub class="h-5 w-5 text-primary-500" />Browse icons at Icônes or icon-sets.iconify.design.
For dynamic icons driven by content data (e.g. a skill JSON storing "icon": "hugeicons:test-tube-01"), unplugin-icons can't resolve a runtime path. Use the helpers instead:
SkillIcon.astro— pass a skillslug; resolves the iconify id via@utils/skillsand renders SVG at build time using@iconify/utils.ModalityIcon.astro— forclinic | studio | community. Source SVGs insrc/assets/icons/usecurrentColorand are imported as?rawstrings via@utils/modalityIcons.
src/pages/api/[collection].json.ts exposes every content collection as JSON automatically — /api/pages.json, /api/projects.json, /api/people.json, /api/skills.json, etc. Add a new collection to src/content.config.ts and the endpoint appears with no extra wiring. CORS is open (Access-Control-Allow-Origin: *) per netlify.toml.
There are also two hand-built endpoints:
src/pages/api/altcha/challenge.ts— issues Altcha challenges for the UXD application formsrc/pages/api/apply.ts— verifies the Altcha solution and sends the application via Brevo. Requires server env vars (Brevo API key + recipient address); see the file for the exact names.
src/pages/rss.xml.ts generates an RSS feed of project updates.
The site is wired up to Pages CMS. Configuration lives in .pages.yml and lets editors manage pages, projects, people, organisations, skills, navigation, quotes, and site settings without touching code. Log in at https://app.pagescms.org with the GitHub account that has repo access.
.github/workflows/auto-fix-permalinks.yml keeps page filenames and permalink fields in sync bidirectionally. Renaming src/content/pages/foo.yaml → bar.yaml rewrites the permalink; changing the permalink renames the file. The Python script lives at .github/scripts/auto_fix_permalinks.py and the workflow commits any fixes back to the branch.
src/
├── assets/ # Images and SVGs (referenced by absolute path)
├── components/
│ ├── atoms/ # Button, Heading, Text, Tag, SkillIcon, ModalityIcon, …
│ ├── molecules/ # Card, ProjectCard, QuoteCard, FilterBar, NavItem, …
│ ├── organisms/ # Head, Footer, Navigation, MobileMenu, RichSearch, SkillsCloud, …
│ └── sections/ # Page section blocks composed in YAML (and Sections.astro switch)
├── content/
│ ├── pages/ # YAML pages (one file = one route)
│ ├── projects/ # YAML case studies
│ ├── people/ # JSON team profiles
│ ├── phases/ # JSON phase definitions
│ ├── modalities/ # JSON modality definitions
│ ├── skills/ # JSON skill definitions
│ ├── organisations/ # JSON clients / partners / funders
│ ├── quotes/ # JSON testimonials
│ ├── navigation/ # JSON menu structures
│ └── site/config.json # Global site config
├── content.config.ts # Collection schemas (Zod)
├── layouts/
│ ├── BaseLayout.astro # Document shell (<html>, <Head>)
│ ├── PageLayout.astro # Header + footer wrapper
│ └── SectionLayout.astro # Section wrapper with vertical-rhythm tiers
├── lib/
│ ├── config.ts # Re-exports site config as a typed const
│ └── rehype-table-align.ts # Custom rehype plugin
├── pages/
│ ├── index.astro # Homepage (renders home.yaml)
│ ├── [...path].astro # All other YAML pages
│ ├── projects/[slug].astro # Project detail pages
│ ├── people/[id].astro # Person detail pages
│ ├── 404.astro
│ ├── rss.xml.ts # Project-updates RSS
│ └── api/
│ ├── [collection].json.ts # Auto-generated JSON for every collection
│ ├── altcha/challenge.ts # Altcha challenge issuer
│ └── apply.ts # UXD application submission (Brevo)
├── styles/
│ ├── global.css # Base imports
│ ├── colors.css # @theme tokens, phase gradient SoT
│ ├── typography.css # Heading/body/link defaults
│ ├── components.css # Button/card/tag variants
│ ├── utilities.css # Site-specific utilities
│ └── breakpoints.css
└── utils/ # content.ts, dev.ts, projects.ts, people.ts, skills.ts, …
@astrojs/netlify adapter; build runs on push to main via Netlify's GitHub integration. The site URL is set from DEPLOY_PRIME_URL / URL (Netlify-provided) at build time and falls back to siteConfig.url for local dev — see astro.config.mjs.
The code in this repository is released under the MIT License.
This project follows the Contributor Covenant code of conduct. To report unacceptable behaviour, email conduct@draftlab.org.
- Astro Docs — content collections, image optimisation, view transitions
- Tailwind CSS v4 — the
@themeblock model - Pages CMS Docs
- Atomic Design — the component-hierarchy rationale
.localconfig/CLAUDE.md— extended notes on conventions and gotchas (also consumed by AI tooling)