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, document, section_summary), 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, version: 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. version (default 0) records the compose-logic version that produced the raster; when it is below the editor’s current version the raster is regenerated on the next save |
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. Both limits are enforced in the editor and again by the upload route, which reads them from the draft’s site config |
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) |
Reserved page slugs
Section titled “Reserved page slugs”RESERVED_SLUGS (packages/primitives/src/schemas/site-config.ts): edit, api, login, set-password, 404, audiences. A page can never be created or renamed to one of these — they would shadow an injected platform route. audiences was reserved after client sites already existed, so an index that already contains a page slugged audiences still parses (grandfathered — otherwise the site and editor would refuse to load); the injected /audiences route shadows such a page until it is renamed in the editor.