Skip to content

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 root src/sections.ts file; 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 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.

OptionTypeRequiredNotes
authAuthProviderYesAuthentication implementation. Use supabaseAuth() from @drawnagency/auth-supabase for full OAuth + email flows, or passwordAuth() for the shared-secret fallback.
storageStorageProviderYesContent and media storage. Use githubStorage() from @drawnagency/github. Missing storage throws at config evaluation time.
assetsAssetStoreNoLarge-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.
documentsDocumentStoreNoPrivate-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.
mediaMediaProviderNoMedia serving provider. Defaults to githubMedia() from @drawnagency/core/config.
deployStatusDeployStatusProviderNoDeploy status integration shown in the editor header. supabaseDeployStatus() and netlifyDeployStatus() are exported from @drawnagency/core/config.
collabCollabProviderNoLive 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.
sectionsSectionDefinition[]NoType-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"NoTyped 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 }NoSite metadata. name sets the browser tab title; when omitted, the value from site-config.json’s siteName is used.
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" },
});

Stored at src/content/site-config.json. Validated against SiteConfigSchema in packages/primitives/src/schemas/site-config.ts.

KeyTypeDefaultNotes
siteNamestring"Brand Portal"Display name shown in the nav header
primaryColorstring"#009ca6"6-digit hex (#rrggbb). Primary brand colour used for interactive elements and accents
primaryContraststring"#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
primaryColorDarkstring | nullnull6-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
primaryContrastDarkstring | nullnull6-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
headingFontstring"system-ui"CSS font-family for headings. Allows letters, digits, spaces, commas, quotes, hyphens (max 120 chars)
bodyFontstring"system-ui"CSS font-family for body text. Same character rules as headingFont
uppercaseHeadingsbooleantrueApply text-transform: uppercase to link_heading sections
uppercaseSubheadingsbooleantrueApply text-transform: uppercase to sub_heading / sub_sub_heading sections
uppercaseNavHeadingsbooleantrueApply text-transform: uppercase to nav heading items
googleFontsUrlstring | nullnullMust start with https://fonts.googleapis.com/. Injected as a <link> in <head>
faviconstring | nullnullImage 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 } | nullnullOptional 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 } | nullnullThe 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
descriptionstring | null (max 300)nullOptional site description. Drives og:description and <meta name="description">

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:

TokenDerived fromNotes
--color-primaryprimaryColor (+ primaryColorDark)Plain hex, or light-dark(<light>, <dark>) when darkMode is "optional" and a dark variant is set
--color-primary-contrastprimaryContrast (+ primaryContrastDark)The accent: button labels, text selection, highlights
--color-on-primaryprimaryColor (+ 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.

KeyTypeDefaultNotes
media.sizesnumber[][640, 1080, 1920]Pixel widths for generated WebP variants
media.maxFileSizenumber5242880Maximum 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.maxAssetSizenumber209715200Maximum 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.qualitynumber (1–100)85WebP encoding quality

Note: Some site-config.json files include a media.adapter field (e.g., "github"). This field is not part of MediaConfigSchema and is silently stripped by Zod. The media adapter is configured in portal.config.mjs via the media option.

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).

KeyTypeDefaultNotes
chatbot.enabledbooleanfalseEditor-level switch for the widget
chatbot.namestring"Portal Assistant"Assistant display name — widget header and the system prompt’s self-introduction. Blank falls back to Portal Assistant. Max 60 chars
chatbot.audienceNamesRecord<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.thinkingPhrasesstring[][]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.systemPromptstring""Brand persona and instructions layered onto the built-in prompt. Max 8,000 chars
chatbot.audiencesstring[][]Audience names shown the widget. Strict opt-in: empty = no viewer sees it
chatbot.audiencePromptsRecord<string,string>{}Per-audience prompt overlay appended to the system prompt — only ever in context for that audience’s own requests
chatbot.tasksobject[][]Goal presets shown as a dropdown. Max 20. Each: id (slug, unique), label (≤60 chars), prompt (≤4,000 chars), access (audience names; [] = all)

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.

KeyTypeDefaultNotes
deckBuilder.audiencesstring[][]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.

KeyTypeDefaultNotes
document.enabledbooleanfalseTurns document mode on for the page (toggle in the editor’s Pages modal)
document.meta.clientstring""Header-crumb field; empty fields are omitted from the crumb
document.meta.projectstring""Header-crumb field
document.meta.dateLabelstring""Header-crumb field
document.cover.bgColorstring"#333333"Cover background colour
document.cover.accentColorstring"#EB9500"Cover accent colour
document.cover.titlestring""Cover title
document.cover.labelstring"BRAND GUIDELINES"Cover label line
document.cover.datestring""Cover date line; falls back to meta.dateLabel when empty
document.cover.imageIdstring | nullnullMedia-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.sectionsRecord<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>)”
KeyTypeDefaultNotes
excludedbooleanfalseDrop 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"
dropTextOverflowbooleanfalseTruncate 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
imageLayoutgrid 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)

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.
  • /present toolbar download. The /present/<page-slug> presentation view exposes a download button in its toolbar; it links to the audience-gated /api/document-pdf route, which resolves the manifest and streams the correct pre-rendered PDF (or 404s if the caller’s audience has no variant).
  • DOCUMENT_PDF override. PDF generation is controlled by the DOCUMENT_PDF env var. It defaults on only in the Netlify build (NETLIFY=true); DOCUMENT_PDF=1 force-enables it (e.g. locally) and DOCUMENT_PDF=0 force-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 sets PLAYWRIGHT_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/print scaffold cannot be deleted: shipping that audience-filtered static HTML would be a data-exposure bug, so portal-build-pdfs exits 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 builds apps/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_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.)