Astro site skeleton
_shared/astro-site-skeleton
| Kind | shared |
|---|---|
| Applies to | frontend |
| State | written |
| Stack | astro-6, emdash-0.1, astrojs-cloudflare-13, wrangler-4, d1, r2, spark-emdash-0.6, tokens-css |
| Depends on | _shared/design-system-and-tokens |
| Reference implementation |
|
| Measured on | emdash main 5beb0dd 2026-09-11 |
| Also measured on |
|
| Version | 1 |
Purpose
A public site that a visitor reads without a session: pages are rendered from Astro components whose every visual value comes from one tokens.css, and, in the CMS variant, whose content comes from EmDash running inside the same Cloudflare Worker with a D1 database and an R2 bucket. The invariant that makes it trustworthy is that a brand change is one file (tokens.css) and a content change is one Publish in /_emdash/admin, with nothing in a block that would have to be found and edited by hand.
Two variants share the skeleton: the CMS site (a Worker, output: 'server', EmDash, one D1 and one R2 per site) and the static site (no CMS, astro build to dist/, shipped as bytes) used for portals, docs and marketing pages. The recipe makes the content-cms and forms-leads domains visible on a customer's own domain: the page the editor edits, the block that renders a form embed, the footer that carries the legal links.
When to use
Use it when a recipe says surface: Astro or surface: portal: a client website, a landing page, a documentation site, a developer portal, a brand page, anything a search engine and a first-time visitor must read without signing in. Trigger phrases: "the client's website", "a landing page", "the docs", "a page an editor can change", "the portal".
Use the CMS variant when a non-developer edits the content after launch. Use the static variant when the content lives in the repo (src/data/*.ts, content collections synced from prima-modules) and changes through a commit.
Do not use it for anything behind a session with live state: that is _shared/react-app-skeleton. Do not build a form's submission logic here: the form is a backend-served or iframed widget (forms-leads/ui/client-site-form-embed), and the Astro page only carries the embed and the resize postMessage listener.
Stack assumptions
CMS variant, as measured in emdash-theme-agency/package.json at 3dd00a02 and emdash/templates/starter-cloudflare at 5beb0dd (2026-09-11): astro ^6.0.0, emdash ^0.1.0 (the monorepo's packages/core is 0.1.0; the sites that shipped run 0.1.1, per the wiki's platform page at 92f33ab), @emdash-cms/cloudflare ^0.1.0, @astrojs/cloudflare ^13.0.0, @astrojs/react ^5.0.0 with react ^19 (the admin UI needs it), wrangler ^4.0.0, @astrojs/check for astro check, spark-emdash 0.6.0 (354f86c) for the admin enhancements and the middleware. Tailwind is optional and, when present, layout-only (the blueprint's tailwind.config.mjs extends maxWidth from CSS variables and nothing else). The inventory row also names an emdash 0.22 line; nothing at that version was opened for this recipe, so it is not a pin here.
Static variant, as measured in prima.li/package.json at 9c01b14: astro ^4.16.0 (installed 4.16.19), no adapter, site: 'https://prima.li', plus gsap, lenis and three for that site's motion. A new static site starts on the same Astro major as the CMS variant (6) unless it inherits prima.li's components, in which case it inherits the pin.
Foundations assumed: _shared/design-system-and-tokens (S1) for the shared token layer. The design system for an Astro client site is tokens-css (decision D1b, 2026-09-11): semantic custom properties in src/styles/tokens.css, .t-* typography classes in src/styles/global.css, and the seven blueprint components. Prima's own Astro surfaces (prima.li, the developer portal) use the prima token set, which is the same file shape with Prima's values (prima.li/src/styles/tokens.css).
Data-fetch style: getEmDashEntry(type, id, { locale? }) and getEmDashCollection(type, ...) from emdash (emdash/packages/core/src/query.ts lines 342 and 269 at 5beb0dd) in the page frontmatter, followed by Astro.cache.set(cacheHint); the live collection _emdash from src/live.config.ts. No client-side fetch of CMS content. A form embed or a tracking script is the only network call the visitor's browser makes to Prima, and both go to the backend origin, never to a key.
Environment: the Worker reads DB and MEDIA bindings from wrangler.jsonc; anything sensitive is stored through wrangler's per-Worker encrypted variables and never in the repo. The static variant bakes PUBLIC_* values at build time and nothing else; a value without the PUBLIC_ prefix is not in the bundle. The one the skeleton itself reads is PUBLIC_TRACKING_API_URL, the backend origin the scribe.js loader in the base layout uses for its src and data-api-url (the shipped client site reads it in src/components/layouts/BaseLayout.astro line 45, alchemy.zuerich at bf8c164, 2026-09-12); the developer portal's PUBLIC_PRIMA_API_BASE (S13) is a different value, the gateway base its code samples print, and a site does not reuse one name for the other.
Copy language: German for client sites (Schweizer Hochdeutsch, ss never ß, du-Form unless the client's register says Sie), German for portals, English for prima.li and the developer portal. Legal pages (Impressum, Datenschutz) are required on every Swiss site and linked from the footer.
Data model
Client state is almost none: the visitor's browser holds no session. What exists:
tokens.css(:root): raw palette (--c-*) and the semantic aliases every component reads:--bg-page,--bg-surface,--bg-dark,--fg-default,--fg-muted,--fg-accent,--fg-on-dark,--border-default,--border-strong, the--fs-*scale (every heading step clamped),--lh-*,--space-*,--radius*,--content-width,--content-narrow,--shadow-*,--transition-*,--font-display,--font-body. prima.li's file at9c01b14adds an on-image rebinding block (--on-image-*) that apage--on-imagebody class swaps in so every block inverts without being edited.seed/seed.json: the content schema (collections, fields, block definitions such as<client>.prose), committed with the site. Uploads travel with the seed when a site moves.- EmDash rows: every content row carries
version(non-null, or the row cannot be saved), draft columns and published columns; the public site renders the published columns only. The content API's read envelope is{ "data": { "item": {...}, "_rev": "..." } }(wikiplatform/emdash.mdat92f33ab, line 374):datacomes off first, and_revis a sibling ofitem, not a field in it; aPUTcarries the_revit read or gets a 409, and a caller that misses the envelope creates a duplicate on every push. - Props and types: each component declares
interface Propsin its frontmatter with union-typed variants (Section:width,tone,spacing;Button:variant,href,type,fullWidth;Card:variant,href), and block components take{ node }from the Portable Text tree. - Static variant: content collections under
src/content/with a zod schema insrc/content/config.ts; prima.li'sw8b-content-sourcebranch (abef548) syncsdocs/products,docs/guideandmodules/fromprima-modulesinto gitignored collection folders beforeastro build, and every card field is required so a half-written card fails the build. - Browser storage: none by the skeleton. The tracking script and the consent banner own their own keys and are documented in the analytics-tracking domain.
API surface
Contracts consumed, by variant:
| Contract | Variant | Where | Parsed or emitted |
|---|---|---|---|
getEmDashEntry("pages", slug) returning { entry, cacheHint } | CMS | src/pages/[...slug].astro | entry.data.*, entry.edit.* attribute bags for in-place editing, Astro.redirect("/404") when null |
getEmDashEntry("singletons", "homepage") | CMS | src/pages/index.astro | the homepage's fixed block order, content from the singleton |
getMenu("primary") | CMS | Base.astro | menu.items[] { url, label, target } |
getSeoMeta(entry, { siteTitle, siteUrl, path }) | CMS | CMS pages | title, description, canonical for the head |
/_emdash/admin, /_emdash/api/content/<collection>/<id> | CMS | EmDash's own routes, prerender = false inside the package | the editor; PUT with { "_rev" } must answer 200 (launch check A1) |
/_emdash/admin/spark-data.json | CMS | spark-emdash middleware | { fieldHints, help } for the admin; public, cached 300 s |
<script src="{PUBLIC_TRACKING_API_URL}/static/scribe.js" data-site-id data-api-url={PUBLIC_TRACKING_API_URL}> | both | Base.astro | first-party analytics; no key, the site id is public |
| form embed iframe from the backend | both | a block | postMessage alchemy-labs-form-resize (height), alchemy-labs-form-get-tracking (the page answers with its tracking scalars) |
sitemap.xml, robots.txt | CMS | emitted by EmDash (packages/core/src/astro/routes/, prerender = false) | crawlers |
Error envelopes rendered as copy: a missing entry is a redirect to /404 (the 404 page uses Base and the site's tone); a form embed that fails to load shows the backend's own fallback inside the iframe, not a page-level error. An EmDash write that returns an empty 404 is the context.cache defect in Gotchas, not a content error.
Application logic
- Request arrives at the Worker (CMS) or the static host.
src/middleware.tsrunssequence(sparkEmdash({ layouts, ... })): for/_emdash/admin/*HTML responses it injects the admin CSS and JS before</head>; for everything else it passes through (spark-emdash/src/middleware.tslines 27 to 60). - The route matches:
index.astro(hardcoded block order, singleton content),[...slug].astro(fully CMS-driven, theBlocks.astrorouter maps each Portable Text block type to a component undersrc/components/blocks/),404.astro, or a static page. - The page's frontmatter queries EmDash and calls
Astro.cache.set(cacheHint)on the next line. Nothing between the query and the cache call. - The page renders inside
Base.astro:EmDashHead(SEO tags, plugin head injection),EmDashBodyStart, the site'sNavigation,<main><slot /></main>, the site'sFooter,EmDashBodyEnd.global.cssis imported once inBase.astroand importstokens.csson its first line. - Every block wraps itself in
Section(width,tone,spacing), composesSectionIntro(which composesEyebrow),CardandButton, and reads only semantic tokens and.t-*classes in its scoped<style>. - An editor opens
/_emdash/admin, edits, Saves (a draft revision), Publishes (the live columns change). The public page reflects the change on the next request; withsession: 'auto'on D1 the read follows the write bookmark. - A visitor submits a form: the iframe posts to the backend; the page's listener resizes the iframe and answers the tracking request; nothing on the page holds the submission.
- Static variant:
npm run buildruns the content sync (when the site consumesprima-modules) thenastro build;dist/is the artefact; redirects for moved URLs are declared inastro.config.mjsredirectsso static output writes one redirect page per old URL (prima.liabef548).
Invariants
- No raw value in a component
<style>: no hex, no pixel font size, no pixel spacing except0, no literalfont-family, no shadow with a literal colour (design-system-standards Rule 1). A value the design needs and the scale lacks is added totokens.csswith a name. - Semantic names only in components (
--fg-accent, never--c-violetor--color-3). - Every
--fs-*heading token clamps, with the same desktop cap and mobile floors that keep the order (Rule 9; the h4-above-h3 inversion at 390 px was measured on a shipped site on 2026-07-23). Button,Section,Eyebrow,SectionIntro,Card,Navigation,Footerand theProseblock exist before the first custom block, and a block imports them rather than restyling a<a>.Astro.cache.set(cacheHint)follows every EmDash query.- A CMS page is
prerender = false; a page that never changes may prerender, and a page withgetEmDashEntryin it may not. - A grid track is
minmax(0, 1fr), never bare1fr; a flex child that holds text hasmin-width: 0(German compound words burst the page otherwise). - Legal links in the footer on every page; the 404 page uses the site layout.
- Content rows keep a non-null
version; seed and uploads move together. patches/and thepostinstallline are committed with the site when a patch is in use.
Frontend
The rendering is the page shell every Astro recipe's tree starts from:
<html lang="de">
head
charset, viewport, <title>, description, canonical
EmDashHead page={pageCtx} (CMS variant; SEO + plugin head)
<link rel="preload"> primary display font, .woff2 from public/fonts/
global.css -> @import tokens.css (.t-* classes, @font-face, reset)
body[class: page--on-image?]
EmDashBodyStart page={pageCtx} (CMS variant)
Navigation logo -> "/", menu items (getMenu("primary") or src/data), burger below md, no-JS fallback
main
<slot/>
index.astro: Section(hero) > SectionIntro + Button ... fixed order from the singleton
[...slug]: Blocks.astro > Section per block > { Prose | <client>.<block> ... }
404.astro: Section(narrow) > t-h1 + Button(href="/")
Footer brand, links, Impressum, Datenschutz, copyright
EmDashBodyEnd page={pageCtx} (CMS variant)
scribe.js loader (data-site-id, data-api-url = PUBLIC_TRACKING_API_URL)
Tokens used by the shell: --bg-page on body, --fg-default for text, --font-body on body, --content-width and --page-gutter in Section, --radius on Button (prima.li: 20 px pill), --radius-lg on Card, --shadow-elevated on a linked card's hover (translateY(-4px)), translateY(-1px) on a button's hover, --transition-fast on both, --space-section for vertical rhythm. prima.li's Section.astro at 9c01b14 shows the extended prop set a real site grew into: width narrow | measure | content | wide | span | full, tone default | surface | dark | transparent | stone | accent | paper, spacing sm | md | lg | xl, plus gutter, finish, round and a backdrop named slot.
States: loading does not exist (server-rendered); empty is the 404 page and, for a list block, the sentence the block renders when entries.length === 0; error is the 404 redirect for a missing entry. Responsive: Navigation collapses to a burger below md; every heading uses its own --fs-* token at every breakpoint (no mobile downgrade of an h2 to --fs-h3); horizontal overflow is measured, not eyeballed (see Tests). Screenshot reference: the site's /design-system route when it has one (astro-design-system-showcase in ui-design-system; the pattern was first built on prima.li), otherwise the recipe-level PNG.
Security & tenancy
What the browser may hold: nothing of Prima's. No key, no token, no workspace id. The tracking site id and the form code in an embed URL are public identifiers by design; the backend derives the tenant from the resource row they point at, never from anything the page sends.
Origin rules: the site is its own origin; the form iframe and the tracking script talk to the backend origin; the page listens for postMessage from the iframe and checks event.origin against the backend origin before acting. The public-embed CORS on the backend allows the site's origin without credentials.
The CMS admin: /_emdash/admin is EmDash's own auth (passkey or magic link). No debug API token is left in the repo or in a committed seed (launch check C3). emdash:site_url is the production domain (C1) so magic links and canonicals resolve.
Surface gating from /me.surfaces does not apply: nothing here is behind a Prima session. A portal that needs one is a hybrid and says so in its own recipe.
Hidden by CSS versus enforced: nothing on a public site is hidden by CSS as a security measure; a page that must not be public is not built.
Fail direction: a missing entry is a 404, never a page with empty blocks; a failed EmDash write is a visible admin error (after the context.cache fix), never a silent half-write; a build that cannot satisfy a content schema fails.
Attacker-controlled inputs: the slug (matched against the collection, redirect on miss), postMessage payloads (origin-checked, height clamped), query strings (ignored by the pages; the tracking script reads UTM parameters as allowlisted scalars).
Tests
astro check(npm run typecheckin the template) for the component props and frontmatter.npm run buildas the gate for the static variant: a content collection row that misses a required field fails the build (prima.liw8b-content-source,src/content/config.ts, every field required).- The content-card gate:
npm run check:docson prima.li's content branch runsscripts/check-module-cards.mjsover the synced cards (0 problems atabef548per the inventory, 2026-09-11). - Overflow sweep (design-system-standards Rule 9): a same-origin iframe at 390, 768, 1024 and 1280 px per route and locale, asserting
documentElement.scrollWidth - clientWidth === 0. This is anodescript against the built site (below) or a Playwright spec; the wiki's measurement on one shipped site was 72 route-locale-breakpoint combinations with 4 breaks, one at 1024 px. The script, withplaywrightinstalled beside it,node sweep.mjs http://127.0.0.1:4321 / /leistungen /kontaktagainstastro previewor any static server overdist/(run 2026-09-12 against a two-page scratch site: a page with a 1400 px child and a throwing script broke at all four widths, a clean page at none, exit 1):
// Overflow sweep: node sweep.mjs <base url> <route> [<route> ...]
import { chromium } from 'playwright';
const [base, ...routes] = process.argv.slice(2);
const widths = [390, 768, 1024, 1280];
const browser = await chromium.launch();
let breaks = 0;
for (const route of routes) {
for (const width of widths) {
const page = await browser.newPage({ viewport: { width, height: 900 } });
const errors = [];
page.on('pageerror', (e) => errors.push(e.message));
await page.goto(new URL(route, base).href, { waitUntil: 'networkidle' });
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
if (overflow !== 0 || errors.length) { breaks += 1; console.log(`BREAK ${route} @${width}: overflow ${overflow}px, page errors ${errors.length}`); }
await page.close();
}
}
await browser.close();
console.log(`${routes.length * widths.length} combinations, ${breaks} breaks`);
process.exit(breaks ? 1 : 0);
- Link resolution (launch check F2): grep every internal
hrefin the built HTML and diff againstsrc/pages/; on a multilingual site, grep/enand/frpages for unprefixed internal links (E1) and assert none. - Save round trip (launch check A1):
GET /_emdash/api/content/pages/<id>for its_rev,PUTit back with{ "_rev": "<rev>" }, expect 200. This is the one test that catches thecontext.cachedefect. - Version check (A2):
SELECT SUM(version IS NULL) FROM ec_<collection>on the remote D1 is 0 for every collection. - Pure helpers (
src/lib/*.ts, locale path builders, formatters) run undernode --test. - The Playwright gate for a site that embeds a form: load the page, submit the form in the iframe, assert the lead lands (the forms-leads recipe owns the assertion).
Deploy & ops
CMS variant: npm run deploy is astro build && wrangler deploy (emdash-theme-agency/package.json at 3dd00a02). One Worker, one D1 (binding: DB, session: "auto"), one R2 (binding: MEDIA) per site, declared in wrangler.jsonc with compatibility_flags: ["nodejs_compat"] and a real database_id (the template ships "local" as a placeholder). Custom domain flip, encrypted variables and the per-client naming are in _shared/cloudflare-worker-and-estate-deploy. After deploy: npx wrangler deployments list shows one live version; npx wrangler d1 info <db> says which read-replication mode the site is in; npx wrangler tail <worker> --format json is the only place an empty 404 shows its stack.
Static variant: astro build to dist/; the bytes are shipped as-is by one of two paths, both in _shared/cloudflare-worker-and-estate-deploy (S13): a Cloudflare Pages direct upload (wrangler pages deploy dist --project-name <project> --branch main) for prima.li's marketing site and its index host, each its own Pages project since 2026-08-02 (wiki platform/primaos.md); or rsync into a directory the host Caddy already serves, the deploy-astro path for the client sites and prima-developer/deploy/ship.sh for the developer portal. Both deploy from a local build as of 2026-09-11 with no CI. redirects in astro.config.mjs produce one redirect page per moved URL.
Env baked at build: PUBLIC_* only (PUBLIC_TRACKING_API_URL for the tracking loader, Stack assumptions). site in astro.config.mjs is the canonical origin. No .env with anything sensitive is committed; wrangler holds the Worker's variables.
CSP and CORS: the backend's public-embed CORS lists the site origin; the site's own headers allow the backend origin for the iframe (frame-src) and the script (script-src), and the fonts are self-hosted so no third-party font origin is needed.
Rollback: CMS variant, wrangler rollback to the previous deployment (content is in D1 and unaffected); static variant, redeploy the previous dist/.
Gotchas
- 2026-06-04 (measured on the first shipped EmDash site,
emdash 0.1.1+astro 6.3.1; wikiplatform/emdash.mdat92f33ab): content saves return an empty 404 after the DB write because EmDash's 11 write routes callcontext.cache.enabledand Astro leavescontext.cacheundefined for endpoints unlessexperimental.cacheis on. Fix that shipped:patches/emdash+0.1.1.patchnull-guardingcache?.enabled, applied by"postinstall": "patch-package"; the alternative (enableexperimental.cache) is not verified against the Cloudflare adapter. Commit the patch; an uncommitted patch is lost on the next clean install. - 2026-06-04 (measured): rows seeded by raw SQL can carry
version = NULL, andversion + 1staysNULLin SQLite, so the row can never be saved. Fix:UPDATE ec_<collection> SET version = 1 WHERE version IS NULL. - 2026-06-04 (measured): Save writes a draft; the public site renders the published columns. An edit "not showing" is an unpublished draft.
- 2026-06-04 (measured, multilingual): content rows store one canonical German href; a block that renders
href={c.href}raw on/enlinks to the German page, andgetEmDashEntrywithout{ locale }returns the default-locale row mid-English-page. Fix both locales with one mechanism (localePath,{ locale }on every query). - 2026-07-23 (measured, design-system-standards Rule 9):
--fs-h1to--fs-h3clamped and--fs-h4to--fs-h6fixed, so anh4rendered larger than theh2above it at 390 px for months. Fix: every heading token clamps. - 2026-09-11 (measured): the blueprint at
92f33abimportsemdash/integration,@emdash-cms/cloudflare/d1and.../r2; the monorepo's templates at5beb0ddand the agency theme at3dd00a02importemdash from "emdash/astro"and{ d1, r2 } from "@emdash-cms/cloudflare". Follow the templates; the blueprint's import paths are stale. - 2026-09-11 (measured): the monorepo's templates carry no
src/middleware.tsand notokens.css; both come from the blueprint and fromspark-emdash, not fromnpm create emdash. - 2026-09-11 (measured): the inventory describes prima.li's tokens as purple
#6844C4, sand#FBF8F3, ink#1D1B17, 1120 px measure. The file at9c01b14has--c-violet: #6642C1,--c-cream: #f4f1ec,--c-ink: #1a1420,--content-width: 1360pxand--statement-title-col: 70rem(1120 px). The purple differs from the D1a canonical#6844C4by two hex digits and is one of the reconciliations S1 owns;--work-purplein the same file already is#6844C4. - 2026-09-11 (measured): prima.li's
tokens.csscarries per-case-study band tokens and a stone ramp scoped to the hero. A site that inherits the file strips both; they are prima.li's, not the skeleton's. - 2026-09-11 (measured):
getEmDashEntryreturnsnullfor an unknown slug and the template redirects to/404; a page that forgets the null check renders a blank article with a 200. - Rendering trap (Rule 11, wiki): a state class (
.done,.active,.open) must never carry layout rules; a layout named after a state disappears with the state. - Rendering trap (Rule 12, wiki): a fill colour is not a text colour; every semantic colour used as text clears 4.5:1 on the canvas it sits on, not only on white.
- Rendering trap (D1 read consistency, launch check): do not copy
session: "auto"without knowing whether D1 read replication is on for that database.
Reference implementation
CMS variant, in the order a new site is assembled. Paths are relative to each repo's root; commits as in measured_on.
emdash/templates/starter-cloudflare/astro.config.mjs(5beb0dd) lines 1 to 23:output: "server",adapter: cloudflare(),react(),emdash({ database: d1({ binding: "DB", session: "auto" }), storage: r2({ binding: "MEDIA" }) }),devToolbaroff.emdash/templates/starter-cloudflare/wrangler.jsonclines 1 to 20:nodejs_compat, theDBandMEDIAbindings, the placeholderdatabase_id.emdash/templates/starter/src/live.config.ts:_emdash: defineLiveCollection({ loader: emdashLoader() }).emdash/templates/starter/src/layouts/Base.astrolines 1 to 75:getMenu("primary"),createPublicPageContext,EmDashHead(45),EmDashBodyStart(48),EmDashBodyEnd(72),WidgetArea,LiveSearch.emdash/templates/starter/src/pages/[slug].astrolines 1 to 45:getEmDashEntry("pages", slug)(12), the null redirect,Astro.cache.set(cacheHint)(18),getSeoMeta(20),page.edit.*attribute bags on the rendered nodes.emdash/packages/core/src/astro/routes/(admin.astro,sitemap.xml.ts,robots.txt.ts,api/*.ts): every EmDash route isprerender = false.spark-emdash/src/middleware.ts(354f86c) lines 27 to 60:sparkEmdash(config);src/types.tslines 78 to 89:SparkConfig(layouts,illustrations,previews,dependencies,fieldHints,help,branding,repeaters).emdash-theme-agency/astro.config.mjsandpackage.json(3dd00a02): the same config on a shipped Alchemy theme,deploy: astro build && wrangler deploy,bootstrap: emdash init && emdash seed,"emdash": { "seed": "seed/seed.json" }.- Wiki
guides/emdash-blueprint.md(92f33ab): the file tree, the seven components with their prop tables,Prose.astro,Blocks.astro, the minimumseed/seed.json,tailwind.config.mjs, the checklist. Wikiguides/design-system-standards.md: Rules 1 to 13 and the health checklist. Wikiguides/emdash-launch-check.md: A1 to F6 and the one-shot smoke test.
Token layer and static variant:
prima.li/src/styles/tokens.css(9c01b14)::rootat line 8, raw palette 9 to 16, semantic aliases 18 to 41, on-image rebinding 43 to 51, fonts 67 to 72, the clamped scale 74 to 83, line heights 101 onward, spacing 123 to 136, radii 138 to 143, layout widths 155 onward, shadows 191, motion 204.prima.li/src/styles/global.css:@import './tokens.css'(4),@font-facefor the four self-hosted faces, the reset (bodyat 40),.t-displayto.t-caption(52 to 59),.t-section-title(65).prima.li/src/layouts/PrimaBase.astro:import '../styles/global.css'(8), theProps(14 to 25),PrimaNav(58),<main><slot /></main>(59 to 61),PrimaFooter(62), thepage--on-imagebody class.prima.li/src/components/Section.astro: the extendedProps(41 to 61) and thebackdropslot.Button.astro: seven variants (5),Tag = href ? 'a' : 'button'(12), the.btnrules on tokens only.prima.libranchw8b-content-source(abef548, unmerged):scripts/sync-content.mjs,src/content/config.ts, theredirectsentry inastro.config.mjs.
Deviations of the references from this recipe, dated 2026-09-11: the monorepo templates have no tokens.css, no middleware.ts and use element selectors instead of .t-* classes; the blueprint's import paths are stale; prima.li is on Astro 4 and its accent differs from the canonical purple; the agency theme has no middleware.ts and no tokens.css under src/styles/ (it carries global.css only). The blueprint component bodies a builder of this kit can read: Button, Section, Eyebrow, Navigation, Footer and FeatureCard under modules/ui-design-system/ui/astro-design-system-showcase/components/ (the measured site's files at 77291bb, adapted as each file's header says; that site's Button has variants primary | secondary | tertiary and a surface prop where the blueprint says primary | secondary | ghost, and its Section tones are default | dark | transparent); SectionIntro and Card below, copied from the wiki blueprint (guides/emdash-blueprint.md at 92f33ab, sections 4 and 5; the wiki is not readable from a kit checkout, which is why they are here); Prose is CMS-only (Portable Text through emdash) and stays a wiki reference. Until a packages/astro-blueprint workspace carries all seven (RENDERING.md open item 7.5), these files and the prop tables under Data model are the specification a new site writes its components to.
SectionIntro.astro, as in the wiki (the two spacing shorthands are written as margin-block and margin-inline pairs here, same computed values, because this kit's content gate refuses the bare shorthand's name):
---
import Eyebrow from "./Eyebrow.astro";
interface Props {
eyebrow?: string;
heading?: string;
description?: string;
tone?: 'default' | 'on-dark';
align?: 'start' | 'center';
}
const { eyebrow, heading, description, tone = 'default', align = 'start' } = Astro.props;
---
<div class:list={["section-intro", `section-intro--${align}`]}>
{eyebrow && <Eyebrow tone={tone}>{eyebrow}</Eyebrow>}
{heading && <h2 class="t-h2 section-intro-heading">{heading}</h2>}
{description && <p class="t-body-1 section-intro-description">{description}</p>}
{Astro.slots.has('default') && <div class="section-intro-body"><slot /></div>}
</div>
<style>
.section-intro { max-width: var(--content-narrow); }
.section-intro--center { text-align: center; margin-left: auto; margin-right: auto; }
.section-intro-heading { margin-block: var(--space-sm) 0; margin-inline: 0; color: var(--fg-default); }
.section-intro-description { margin-block: var(--space-md) 0; margin-inline: 0; color: var(--fg-muted); }
.section-intro-body { margin-top: var(--space-lg); }
</style>
Card.astro, as in the wiki less its unused import { Image } from "emdash/ui" line, which the static variant cannot resolve:
---
interface Props {
variant?: 'default' | 'cream' | 'dark' | 'accent';
href?: string;
class?: string;
}
const { variant = 'default', href, class: className } = Astro.props;
const Tag = href ? 'a' : 'article';
---
<Tag
href={href}
class:list={["card", `card--${variant}`, { "card--linked": !!href }, className]}
>
{Astro.slots.has('media') && (
<div class="card-media"><slot name="media" /></div>
)}
<div class="card-body">
<slot />
</div>
</Tag>
<style>
.card {
background: var(--card-bg, var(--bg-surface));
color: var(--card-fg, var(--fg-default));
border: 1px solid var(--card-bd, var(--border-default));
border-radius: var(--radius-lg);
overflow: hidden;
text-decoration: none;
display: flex;
flex-direction: column;
}
.card--linked {
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
}
.card--linked:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-elevated);
}
.card--linked:active { transform: translateY(-2px); }
.card--cream { --card-bg: var(--bg-surface); }
.card--dark { --card-bg: var(--bg-dark); --card-fg: var(--fg-on-dark); --card-bd: transparent; }
.card--accent { --card-bg: var(--fg-accent); --card-fg: var(--fg-on-dark); --card-bd: transparent; }
.card-media { overflow: hidden; }
.card-media :global(img) { width: 100%; height: auto; display: block; }
.card-body { padding: var(--space-xl); flex: 1; }
</style>
Acceptance checks
Run from the site directory after npm ci. Checks 1, 2, 9 and 10 are the CMS variant's; 3 has a line per variant; 4 to 8, 11 and 12 apply to both variants; 13 to 15 are the static variant's.
grep -n "emdash/astro\|@emdash-cms/cloudflare" astro.config.mjsprints both imports;grep -n "output" astro.config.mjsprintsoutput: "server".grep -c "binding" wrangler.jsoncis at least 2 andgrep -n '"database_id": "local"' wrangler.jsoncprints nothing on a deployable site.- Both variants:
ls src/styles/tokens.css src/styles/global.css src/layouts/Base.astro src/components/{Button,Section,Eyebrow,SectionIntro,Card,Navigation,Footer}.astrolists every file. CMS variant, in addition:ls src/middleware.ts src/live.config.ts src/components/Blocks.astro src/components/blocks/Prose.astro seed/seed.jsonlists every file. - (Both variants, as are 5 to 8, 11 and 12.)
grep -rnE "#[0-9a-fA-F]{6}" src/components src/layouts src/pagesprints nothing, andgrep -rnE "font-family: *'" src/componentsprints nothing: raw values live only insrc/styles/tokens.cssand the@font-facerules insrc/styles/global.css. grep -rn "getEmDashEntry\|getEmDashCollection" src/pages | wc -lequalsgrep -rn "Astro.cache.set" src/pages | wc -l.grep -n "clamp(" src/styles/tokens.css | grep -c "fs-h"equals the number of--fs-h*tokens defined.grep -rn "1fr" src | grep -v "minmax(0, 1fr)"prints nothing.npx astro checkexits 0;npm run buildexits 0.npx emdash devstarts; open/_emdash/admin, add a page with a Prose block, Save, Publish;curl -s -o /dev/null -w "%{http_code}" -X PUT -H "Authorization: Bearer $TOKEN" -H "X-EmDash-Request: 1" -H "Content-Type: application/json" "$BASE/_emdash/api/content/pages/<id>" --data '{"_rev":"<rev>"}'prints200; open the page's public URL and observe the block.- Browser walk-through per state: the homepage renders the singleton's blocks in the fixed order; a CMS page renders its stacked blocks; an unknown slug lands on the 404 page inside the site layout with a link to
/; at 390 px the burger opens and closes without JS disabled breaking the links; the footer shows Impressum and Datenschutz on every page. - Overflow sweep:
node sweep.mjs <base url> <every route>(the script under Tests) prints<N> combinations, 0 breaksand exits 0; for each route at 390, 768, 1024 and 1280 px,documentElement.scrollWidth - documentElement.clientWidthis0and no page error was thrown. - Screenshot diff of the
/design-systemroute (or the block gallery) against the S1 rendering reference at 1280 px; button hover lifts 1 px, linked card hover lifts 4 px.
Static variant, run from the site directory:
npm run buildexits 0 anddist/index.htmlexists; on a site that consumesprima-modules,ls src/prima-modules/source.jsonexists and names the source commit.grep -rn "PUBLIC_" src | grep -v "import.meta.env.PUBLIC_"prints nothing, andgrep -rn "import.meta.env\." src | grep -v "PUBLIC_\|SSR\|DEV\|PROD\|MODE\|BASE_URL\|SITE"prints nothing.curl -s https://<site>/<moved-url>returns a page whose<meta http-equiv="refresh">and<link rel="canonical">name the new URL.
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.