Config reference
Portal configuration lives in two places:
portal.config.mjs— server-side runtime config (auth, storage, deploy status). Read by the Astro integration at build time and at SSR request time. Custom section types are not configured here — they register from a rootsrc/sections.tsfile; see Building custom sections.src/content/site-config.json— visual/brand configuration (colours, fonts, media settings). Read by the content loader and shipped as part of the site bundle.
defineConfig options
Section titled “defineConfig options”defineConfig is imported from @drawnagency/core/config (not the root @drawnagency/core export, which includes Node-only integration code).
import { defineConfig } from "@drawnagency/core/config";Source type: PortalConfig in packages/core/src/config.ts.
| Option | Type | Required | Notes |
|---|---|---|---|
auth | AuthProvider | Yes | Authentication implementation. Use supabaseAuth() from @drawnagency/auth-supabase for full OAuth + email flows, or passwordAuth() for the shared-secret fallback. |
storage | StorageProvider | Yes | Content and media storage. Use githubStorage() from @drawnagency/github. Missing storage throws at config evaluation time. |
assets | AssetStore | No | Large-file storage for self-hosted video over the git cap (media.maxFileSize, default 5MB) and up to media.maxAssetSize (default 200MB). Use r2Assets() from @drawnagency/assets-r2 (Cloudflare R2). In platform mode it needs no new env vars — every operation is brokered through the admin app, which holds the R2 credentials, so a client site needs only the PLATFORM_API_URL / PLATFORM_API_KEY / PORTAL_SITE_ID trio it already has. In standalone mode it needs its own R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET and R2_PUBLIC_BASE (see Environment variable reference). supabaseAssets() from @drawnagency/auth-supabase remains available for sites still on Supabase Storage, but is no longer the default — Supabase serves storage objects as no-cache on plans without CDN purging, which makes every video play billable origin egress. When omitted, the video section still works for small git-backed loops and external YouTube/Vimeo embeds — only the large self-hosted upload path is disabled, with an in-editor message pointing at this option. See Using video for the editor-facing behavior. |
documents | DocumentStore | No | Private-bucket storage for the files behind document sections when they exceed the 2.5 MB git cap. Use r2Documents() from @drawnagency/assets-r2. In platform mode it needs no new env vars — the admin app holds the credentials and brokers upload, download and delete — and in standalone mode it adds only R2_DOCS_BUCKET on top of the R2_* credentials assets already uses (see Environment variable reference). Unlike assets, whose objects have permanent public URLs, a DocumentStore has no durable URL at all: the bucket is private and every read is a short-lived presigned URL issued by the auth-gated /api/document-file/ route. When omitted, documents of 2.5 MB or less still work — they commit to the repository — and larger uploads are refused in the editor with a message pointing at this option. |
media | MediaProvider | No | Media serving provider. Defaults to githubMedia() from @drawnagency/core/config. |
deployStatus | DeployStatusProvider | No | Deploy status integration shown in the editor header. supabaseDeployStatus() and netlifyDeployStatus() are exported from @drawnagency/core/config. |
collab | CollabProvider | No | Live multi-editor collaboration — presence avatars, section-level locks, and live broadcast of saved changes to other open editors. supabaseCollab() from @drawnagency/auth-supabase is the only implementation. No new env vars — presence and locks run over Supabase Realtime using the browser’s existing SUPABASE_URL / SUPABASE_ANON_KEY; the server-side saved-change broadcast reuses SUPABASE_SERVICE_ROLE_KEY (standalone) or PLATFORM_API_URL / PLATFORM_API_KEY (platform mode), the same variables supabaseAuth() already requires (see Environment variable reference). Degrades to solo editing — when collab is absent, the required env is missing, the site uses password-only auth (no Supabase user session), or Realtime is unreachable, the editor runs exactly as it does today. Saving is never affected: it does not depend on the collab transport, and the 409 conflict auto-recovery stays active in solo mode. See Working with other editors for the editor-facing behavior. |
sections | SectionDefinition[] | No | Type-only — not read for registration. Define custom section types in a root src/sections.ts file (see Building custom sections); registration is handled by the virtual:portal/sections channel, which reads that file directly. Passing the same array here only adds a type-check. |
builtins | "core" | "all" | No | Typed contract, not yet wired. Today both built-in groups always register (the generic set + brand-guide: colors, icon_list, dodont_media), so setting this has no effect. "core" is the reserved seam for a future change letting a non-brand-guide site tree-shake the brand-guide group — config.ts notes there is no consumer yet. |
site | { name?: string } | No | Site metadata. name sets the browser tab title; when omitted, the value from site-config.json’s siteName is used. |
Typical portal.config.mjs
Section titled “Typical portal.config.mjs”import { defineConfig, supabaseDeployStatus } from "@drawnagency/core/config";import { supabaseAuth, supabaseCollab } from "@drawnagency/auth-supabase";import { r2Assets } from "@drawnagency/assets-r2";import { githubStorage } from "@drawnagency/github";
export default defineConfig({ auth: supabaseAuth(), storage: githubStorage(), assets: r2Assets(), // optional; the template includes it by default deployStatus: supabaseDeployStatus(), collab: supabaseCollab(), // optional; live multi-editor presence + locks site: { name: "Acme Brand Portal" },});site-config.json
Section titled “site-config.json”Stored at src/content/site-config.json. Validated against SiteConfigSchema in packages/primitives/src/schemas/site-config.ts.
Top-level fields
Section titled “Top-level fields”| Key | Type | Default | Notes |
|---|---|---|---|
siteName | string | "Brand Portal" | Display name shown in the nav header |
primaryColor | string | "#009ca6" | 6-digit hex (#rrggbb). Primary brand colour used for interactive elements and accents |
primaryContrast | string | "#f0f0f0" | 6-digit hex. The accent colour — text drawn on primary-coloured buttons and the text-selection colour. Brands routinely repoint it at a decorative highlight (a gold on aubergine), so it is not a general-purpose “text on primary” ink; for body copy on a primary-filled surface use the derived --color-on-primary token instead (see Derived theme tokens). Editable in the editor’s Display → Styles tab, which shows a live WCAG contrast readout and a Suggest button that picks the black/white with the strongest contrast on primaryColor |
darkMode | "light" | "dark" | "optional" | "light" | "light" / "dark" fix the colour scheme; "optional" exposes a viewer toggle |
primaryColorDark | string | null | null | 6-digit hex. Dark-mode override for primaryColor. Only applied when darkMode is "optional"; null reuses the light value. Rendered via CSS light-dark() (the .dark class sets color-scheme: dark). Editable under Display → Styles → Dark mode variants |
primaryContrastDark | string | null | null | 6-digit hex. Dark-mode override for the primaryContrast accent. Same "optional"-only rule and light-dark() rendering as primaryColorDark |
cornerRadius | "none" | "small" | "medium" | "large" | "medium" | Corner rounding of viewer-facing surfaces (cards, media frames, CTA buttons, chat panel). Sets the --radius-outer design token (0px / 0.25rem / 0.375rem / 0.75rem); --radius-inner derives as max(calc(--radius-outer - 0.25rem), 0px) for nested elements. Custom sections should consume var(--radius-outer) (or calc(var(--radius-outer) - <inset>) for concentric nesting) instead of hardcoding radii. Pill-shaped elements (rounded-full) are unaffected. Editable in the editor’s Display tab |
headingFont | string | "system-ui" | CSS font-family for headings. Allows letters, digits, spaces, commas, quotes, hyphens (max 120 chars) |
bodyFont | string | "system-ui" | CSS font-family for body text. Same character rules as headingFont |
uppercaseHeadings | boolean | true | Apply text-transform: uppercase to link_heading sections |
uppercaseSubheadings | boolean | true | Apply text-transform: uppercase to sub_heading / sub_sub_heading sections |
uppercaseNavHeadings | boolean | true | Apply text-transform: uppercase to nav heading items |
googleFontsUrl | string | null | null | Must start with https://fonts.googleapis.com/. Injected as a <link> in <head> |
favicon | string | null | null | Image data URL (data:image/...). Stored inline so no CDN round-trip is needed |
logo | { light: { imageId: string }, dark: { imageId: string } | null, invertInDark: boolean, showInNav: boolean, hideTitle: boolean } | null | null | Optional light/dark site logo, stored as media-library references (so it participates in usage tracking + GC protection). Shown in the nav (when showInNav) and on the login page. dark falls back to light when null; invertInDark applies a CSS invert filter to the light logo in dark mode when no dedicated dark variant is set. hideTitle (default true) suppresses the site-name text in the nav and on the login page so the logo stands in for it — turn it off to show both. Unlike showInNav, it applies to the login page too. Editable in the editor’s Display tab |
previewImage | { imageId: string, width: number, height: number } | null | null | The composed 1200×630 social-preview raster, stored as a media-library reference. Auto-generated in the editor from the logo/brand inputs (site name, primary color, heading font, dark mode) and drives the og:image / Twitter preview meta tags. width/height are the served media-variant size (the /api/media route only serves exact generated-variant widths), so the emitted tag dimensions are always correct |
description | string | null (max 300) | null | Optional site description. Drives og:description and <meta name="description"> |
Derived theme tokens
Section titled “Derived theme tokens”Not every theme token is a config key. These are computed from the fields above and injected as CSS custom properties on <html> (SSR, by Layout.astro / LoginLayout.astro) and kept live in the editor by EditorShell:
| Token | Derived from | Notes |
|---|---|---|
--color-primary | primaryColor (+ primaryColorDark) | Plain hex, or light-dark(<light>, <dark>) when darkMode is "optional" and a dark variant is set |
--color-primary-contrast | primaryContrast (+ primaryContrastDark) | The accent: button labels, text selection, highlights |
--color-on-primary | primaryColor (+ primaryColorDark) | The ink: foreground for text drawn on a --color-primary-filled surface. Black or white, whichever wins WCAG contrast against the primary — the same pick the accent Suggest button offers. Not configurable, so a primary-filled surface stays legible on every brand even when primaryContrast holds a decorative accent that cannot carry body copy |
Soften the ink toward the surface rather than reaching for a literal white: color-mix(in srgb, var(--color-on-primary) 70%, var(--color-primary)) gives a muted secondary text colour, and 25% a hairline rule, both correct on light and dark primaries.
media sub-object
Section titled “media sub-object”| Key | Type | Default | Notes |
|---|---|---|---|
media.sizes | number[] | [640, 1080, 1920] | Pixel widths for generated WebP variants |
media.maxFileSize | number | 5242880 | Maximum upload size in bytes (default 5 MB) for the git-backed path. Video at or under this size uploads as a small looping clip, same as an image |
media.maxAssetSize | number | 209715200 | Maximum size in bytes (default 200 MB) for self-hosted video routed to the assets bucket. Files between maxFileSize and maxAssetSize require the assets provider to be configured; requests over maxAssetSize are rejected |
media.quality | number (1–100) | 85 | WebP encoding quality |
Note: Some
site-config.jsonfiles include amedia.adapterfield (e.g.,"github"). This field is not part ofMediaConfigSchemaand is silently stripped by Zod. The media adapter is configured inportal.config.mjsvia themediaoption.
chatbot sub-object
Section titled “chatbot sub-object”Configuration for the on-site brand chatbot (editor view: Your brand assistant). The widget appears for viewers only when this is enabled and the platform has enabled chat for the site (admin toggle).
| Key | Type | Default | Notes |
|---|---|---|---|
chatbot.enabled | boolean | false | Editor-level switch for the widget |
chatbot.name | string | "Portal Assistant" | Assistant display name — widget header and the system prompt’s self-introduction. Blank falls back to Portal Assistant. Max 60 chars |
chatbot.audienceNames | Record<string,string> | {} | Per-audience overrides of chatbot.name, keyed by audience name (≤60 chars each). Resolved server-side for both the widget and the prompt |
chatbot.thinkingPhrases | string[] | [] | Brand-flavored waiting phrases shown (with … appended) while the assistant streams no text yet; one is picked at random per response. Empty = Thinking…. Max 30 phrases × 80 chars |
chatbot.systemPrompt | string | "" | Brand persona and instructions layered onto the built-in prompt. Max 8,000 chars |
chatbot.audiences | string[] | [] | Audience names shown the widget. Strict opt-in: empty = no viewer sees it |
chatbot.audiencePrompts | Record<string,string> | {} | Per-audience prompt overlay appended to the system prompt — only ever in context for that audience’s own requests |
chatbot.tasks | object[] | [] | Goal presets shown as a dropdown. Max 20. Each: id (slug, unique), label (≤60 chars), prompt (≤4,000 chars), access (audience names; [] = all) |
deckBuilder sub-object
Section titled “deckBuilder sub-object”Configuration for the Deck Builder — the viewer-facing surface at /decks where audience members build 16:9 presentations from the site’s own brand tokens. Edited in Site Settings → Decks.
| Key | Type | Default | Notes |
|---|---|---|---|
deckBuilder.audiences | string[] | [] | Audience names allowed to create decks — and, through the same gate, to use the deck assistant. Strict opt-in: empty = no viewer can. Editors always can, whatever this says |
A member of a listed audience gets a Decks button on every page; nobody else sees it, and POST /api/decks and POST /api/deck-chat enforce the same rule server-side. Decks are stored on a long-lived decks branch in the site’s repo — never merged to main, and they trigger no deploy. A deck is private to whoever made it: sharing with an audience, and public share links, are not implemented yet.
Working decks are staged in the browser (IndexedDB) and reach the repo only when their author presses Publish deck, so a deck in progress exists on that one device until then.
Decks are authored by chat. The docked assistant rewrites the whole deck each turn and the result is validated server-side before it reaches the preview, so a bad answer never replaces a working deck. It is grounded in the same audience-filtered brand content the chatbot uses, plus a catalog of the site’s media — a deck can only reference images the assistant was offered.
That media catalog is audience-filtered too. A creator is offered only the assets that appear in content they can actually see, plus the site logo. An image used solely on a page their audience cannot open is not in their catalog at all — so its filename and alt text never reach the assistant, and a deck naming it is rejected. Editors see every page, so they get the whole library. The trade-off is deliberate: a creator cannot place an asset they would never encounter in their own view of the portal, even though it sits in the shared media library.
Deck chat is metered separately from the brand chatbot: it has its own per-person and per-site daily limits, so an authoring session cannot spend the Q&A allowance (or vice versa). It shares the platform-level chatbot kill switch, so turning the chatbot off for a site turns deck authoring off too.
index.json — page-level document block (document mode)
Section titled “index.json — page-level document block (document mode)”Each entry in index.json’s pages[] may carry a document object, validated by documentOptionsSchema in packages/primitives/src/document/engine/settings.ts. When enabled, the page gains a paginated presentation view at /present/<page-slug> (viewer FAB links to it) and a full-screen document editor overlay in the editor. All fields have defaults — {} parses to a valid disabled block.
| Key | Type | Default | Notes |
|---|---|---|---|
document.enabled | boolean | false | Turns document mode on for the page (toggle in the editor’s Pages modal) |
document.meta.client | string | "" | Header-crumb field; empty fields are omitted from the crumb |
document.meta.project | string | "" | Header-crumb field |
document.meta.dateLabel | string | "" | Header-crumb field |
document.cover.bgColor | string | "#333333" | Cover background colour |
document.cover.accentColor | string | "#EB9500" | Cover accent colour |
document.cover.title | string | "" | Cover title |
document.cover.label | string | "BRAND GUIDELINES" | Cover label line |
document.cover.date | string | "" | Cover date line; falls back to meta.dateLabel when empty |
document.cover.imageId | string | null | null | Media-library image rendered between the masthead and the title lockup (50% of the band height, natural aspect). null or a deleted image → solid cover |
document.sections | Record<sectionId, SectionSettings> | {} | Per-chunk layout settings, keyed by section id — link_heading/sub_heading ids, or __preamble |
Per-section settings (document.sections.<sectionId>)
Section titled “Per-section settings (document.sections.<sectionId>)”| Key | Type | Default | Notes |
|---|---|---|---|
excluded | boolean | false | Drop the chunk from the document entirely |
textPlacement | "top" | "leftColumn" | "top" | Full-width text above images, or a narrow left column beside them |
imagePlacement | "fullWidth" | "right" | "fullWidth" | Only meaningful with textPlacement: "leftColumn" |
dropTextOverflow | boolean | false | Truncate text that would overflow instead of adding continuation pages |
splitAt | "none" | "sub_heading" | "none" | Also start new pages at sub-heading boundaries; sub-pages get their own settings entries keyed by the sub-heading’s section id |
imageLayout | grid or masonry object | { "mode": "masonry", "flow": "rows", "columns": 3 } | Grid: { mode: "grid", rows: 1–3, columns: 2–4 }. Masonry: { mode: "masonry", flow: "rows" | "columns", columns: 2–4 } |
imageLayout.rows | "auto" | 1–4 | "auto" | Masonry rows flow only; forces an exact row count — the arrangement scales down (never up) so all rows fit the page. "auto" keeps the greedy fill |
imageLayout.fit | "cover" | "contain" | "cover" | Grid only; "cover" crops each image to its cell (Crop), "contain" letterboxes the natural image inside it (Contain) |
Build-time PDFs
Section titled “Build-time PDFs”Document-mode pages ship a downloadable PDF that is rendered at build time, not on demand. On every publish the template runs astro build && portal-build-pdfs: astro build prerenders a hidden /print/… scaffold (one route per audience variant), then the portal-build-pdfs post-build step drives a headless Chromium over each scaffold page and writes the resulting PDFs into the SSR function bundle (.netlify/v1/functions/ssr/document-pdfs/), keyed by a content hash and described by a pdf-manifest.json. The prerendered dist/print scaffold is deleted after rendering so it never ships as a public route.
- Per-audience variants. A separate PDF is generated for each distinct audience view of a document page (identical variants are de-duped by hash), so a reader only ever downloads the version they are authorised to see.
/presenttoolbar download. The/present/<page-slug>presentation view exposes a download button in its toolbar; it links to the audience-gated/api/document-pdfroute, which resolves the manifest and streams the correct pre-rendered PDF (or 404s if the caller’s audience has no variant).DOCUMENT_PDFoverride. PDF generation is controlled by theDOCUMENT_PDFenv var. It defaults on only in the Netlify build (NETLIFY=true);DOCUMENT_PDF=1force-enables it (e.g. locally) andDOCUMENT_PDF=0force-disables it, skipping the render step entirely (useful for fast local builds that don’t need PDFs).- Playwright dependency. Rendering requires a local Chromium via Playwright (
npx playwright install chromium). The build script setsPLAYWRIGHT_BROWSERS_PATH ??= "0"so the browser is co-located with the install. - Fail-soft (one exception). PDF generation is best-effort: if Chromium is unavailable or a render fails, the build logs the failure and continues rather than failing the whole publish — the page still deploys, just without a downloadable PDF for that variant. The only failure that blocks the publish is when the
dist/printscaffold cannot be deleted: shipping that audience-filtered static HTML would be a data-exposure bug, soportal-build-pdfsexits non-zero and fails the build in that case alone. - Gate script.
scripts/pdf-gate.mjs(repo root) is an opt-in real-render check that buildsapps/dev, runs the PDF step, and asserts the scaffold was torn down and every manifest-referenced PDF exists and is a real%PDF-. It is not part of CI — run it manually after touching the pipeline.
Reserved page slugs
Section titled “Reserved page slugs”RESERVED_SLUGS (packages/primitives/src/schemas/site-config.ts): edit, api, login, set-password, 404, present, print, audiences, decks. A page can never be created or renamed to one of these — they would shadow an injected platform route. present, print, audiences and decks were reserved after client sites already existed, so an index that already contains a page slugged present, print or decks still parses (grandfathered — otherwise the site and editor would refuse to load); the injected /present, /audiences and /decks routes and the build-time /print route shadow such a page until it is renamed in the editor. (print backs the build-time PDF scaffold described above.)