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 and GIFs 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. A GIF over it uploaded through the media library is converted to video (MP4, or WebM where the browser can’t encode H.264) in the editor and routed as an ordinary video: committed if it fits, sent to the assets bucket if not, or re-encoded once smaller when assets isn’t configured. A GIF that can’t be converted (real transparency, or no browser encoder) goes to the bucket as its original, or is refused without assets. Saves judge committed media by this value, clamped to a 25 MiB server ceiling |
media.maxAssetSize | number | 209715200 | Maximum size in bytes (default 200 MB) for self-hosted video and large GIFs 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.