Branding & Theming¶
How CampusCore decides what is CampusCore-colored, what is school-colored, and how the three branding image assets (logo, login background, favicon) travel from an admin's file picker to the running app.
The color token architecture¶
The SPA's tokens live in the @theme block of campuscore_app/web/src/styles/index.css. Two groups matter:
--color-brandis a fixed literal (#3b82f6), identical on every deployment. Every interactive control - the primary button (components/ui/Button.tsx), links, focus rings (the global:focus-visibleoutline), checkbox/radio accents, selected states, progress fills, and the analytics charts and controls - readsbrand. A school's palette can never restyle a control, so contrast and the product's look are CampusCore's to guarantee.--color-school/--color-school-accentcarry the school's saved colors. They read the--cc-primary/--cc-secondarycustom properties thatapplyBranding(web/src/lib/api/bootstrap.ts) sets from the bootstrap payload at runtime, so the bundle stays identical across universities. They reach exactly two kinds of surface: the login hero wash (SPALoginScreenand the legacy allauth entry pages) and the sidebar initials square shown when no logo is uploaded. Nothing else may consume them.
Dark mode is an explicit data-theme attribute strategy (@custom-variant dark in index.css, the zustand store in web/src/lib/theme.ts, and the pre-paint script in web/index.html), not prefers-color-scheme; its surfaces are Tailwind's slate scale via dark: variants, with the conversation and settings sidebars always-dark on the same scale.
The stored primary_color/secondary_color fields, their editors (Settings > General & Branding "Brand colours", the setup wizard's single color input), and the hex validation in workspace_branding_api.py are unchanged by all of this - only their blast radius narrowed to the identity surfaces above.
Branding image assets¶
AppConfig (apps/main_app/models/config.py) holds three image fields, all on BrandingStorage (S3 branding/ prefix with 24-hour presigned URLs in cloud, MEDIA_ROOT locally):
| Field | Upload dir | Formats | Size rules |
|---|---|---|---|
institution_logo |
logos/ |
PNG, JPEG, GIF, WEBP | <= 1.5 MB decoded |
login_background |
backgrounds/ |
PNG, JPEG, WEBP | <= 1.5 MB decoded, >= 1920x1080 |
favicon |
favicons/ |
PNG, ICO | <= 256 KB decoded |
The dimension and format rules live in two layers on purpose.
validate_login_background_min_size and validate_favicon on the model fields are the canonical enforcement points - they also cover the Django admin change form, which renders every AppConfig field.
_decode_image in apps/main_app/apis/workspace_branding_api.py re-checks at the API boundary so the client gets a precise 422 (the actual and required dimensions, or the allowed formats).
How the favicon is served¶
No template carries a <link rel="icon">; browsers fall back to probing /favicon.ico, and favicon_redirect (campus_core/favicon.py) answers that probe for every surface - the SPA, the login screen, guest chat, the setup wizard, /cc_admin/, and Django admin - with a 302 to the stored image's URL.
The route is anonymous because the login screen and guest chat need the icon before any session exists; the presigned URL it redirects to is independently fetchable, exactly like the login logo.
With no favicon uploaded the route answers 404 and the browser shows its default blank icon - there is deliberately no bundled fallback icon.
Transport¶
All three images travel as base64 data: URLs inside the one typed-JSON PATCH /api/workspace/branding/update body, as an ImageKeep | ImageSet | ImageClear union discriminated on action (one union, three slots: logo, background, and favicon).
DATA_UPLOAD_MAX_MEMORY_SIZE is set to 6 MB in campus_core/settings.py so all three fit one PATCH; the sizing math is in the setting's comment.
Replacing an image deletes the old storage object best-effort after commit (_StagedImage in the API module), and the audit entry records logo_changed, background_changed, and favicon_changed independently.
On the client, prepareLoginBackground (web/src/lib/loginBackground.ts) rejects undersized images with the actual dimensions before any request, and redraws oversized photos onto a canvas capped at 2560 px wide, re-encoding as JPEG at stepped-down quality, so a many-MB photo becomes a compliant upload instead of an error.
Consumers¶
- SPA login screen:
branding.login_background_urlfrom bootstrap, falling back to the bundledcampus-hero.jpgwhen empty (components/auth/LoginScreen.tsx). - Legacy allauth entry pages (
templates/account/base_entry.html- logout, SSO error, social signup):app_login_background_urlfrom theapp_configcontext processor, falling back to the staticimg/bg.jpg; their overlay is the school wash built from--cc-primary/--cc-secondary.
The tab title¶
applyBranding (web/src/lib/api/bootstrap.ts) composes document.title as "Assistant - Institution" (for example "Torch - Virginia State University") whenever the bootstrap payload resolves.
RequireAuth calls it for every bootstrap state, so the login screen and guest chat carry the composed title too, not just the authenticated app.
The server's display fallbacks guarantee both parts are non-empty, so an unconfigured deployment reads "Assistant - CampusCore"; the only surface showing the static build-time "CampusCore" title is the pre-bootstrap flash.
Django-rendered operator surfaces (/cc_admin/, Django admin, the setup wizard, allauth entry pages) keep their own static titles on purpose.
Bootstrap freshness (why role grants appear without a reload)¶
The SPA learns roles, features, and branding from one GET /api/bootstrap read into the ["bootstrap"] TanStack query.
That payload revalidates on a 30-second contract (BOOTSTRAP_REVALIDATE_MS in web/src/lib/api/bootstrap.ts): refetchOnWindowFocus (which respects staleTime, so at most one check per window) plus a route-change revalidation effect in routes/RequireAuth.tsx.
The assign/revoke-role mutations (web/src/lib/api/users.ts) also invalidate BOOTSTRAP_KEY directly, mirroring the feature toggle's precedent in features.ts, so an admin granting a role to themself sees the gated surface appear live.
RequireAuth shows its full-screen error only when a fetch fails with no cached data - a failed background revalidation keeps rendering the session it has.
Tests pinning this¶
apps/main_app/tests/test_workspace_settings_api.py- background upload visible in bootstrap andbranding_get, undersized 422 naming both sizes, clear/replace/delete lifecycle, GIF rejected for background but not logo, and the favicon contract: upload makes/favicon.icoa 302 for an anonymous browser, clear returns it to 404, formats and the 256 KB cap enforced at both layers.web/src/components/settings/GeneralSettings.test.tsx- the favicon picker's client contract, including the.icoempty-MIME normalization todata:image/x-icon.web/src/components/auth/LoginScreen.test.tsx- custom background used when present, bundled photo otherwise.web/src/lib/api/users.test.tsx- a role grant makes a bootstrap-gated surface appear through the real query wiring.web/src/routes/RequireAuth.test.tsx- stale-payload navigation refetch, in-window navigation no-op, error-with-cache keeps rendering.