Skip to content

Branding the Self Service Portal Developer Guide

Ed Mozley edited this page Sep 24, 2026 · 1 revision

Branding the self-service portal β€” developer guide

User-facing page: Branding the self-service portal.

Settings live in system_settings under self_service_*, read through selfServicePortalSettings() in includes/self_service_settings.php β€” the one place that knows the defaults and validates the stored values.


The logo

Upload goes through includes/uploads.php, the single home for file writes, the same helper save_branding.php uses. It inherits those rules rather than getting a second set: UPLOAD_TYPES_IMAGE (which excludes SVG), a name the app chooses rather than the uploader's, and uploadPrepareWebServableDir() β€” no-exec plus a sandbox CSP, since a logo has to be fetchable.

πŸ”΄ Its own directory

system/uploads/branding/portal/, not system/uploads/branding/.

The tear-down deletes the previous file before writing the next. Pointed at the shared directory, uploading a portal logo would have quietly deleted the main branding logo. Worth asserting explicitly in any test of this.

πŸ”΄ The stored value is a path, not a URL

self_service_logo_path holds a path relative to the app root. Writing it straight into src="" works only from a page at the root; from /self-service/index.php the browser resolves it one directory too deep and 404s.

selfServicePortalLogoUrl() is brandingLogoUrl()'s twin and gives the same two guarantees: BASE_URL in front, and a check the file is still on disk β€” a pointer left by a deleted upload renders a broken image, which reads as a broken install.

⚠️ This cannot be checked from the CLI. BASE_URL is derived from the request, so with no web context it reads as / and the helper looks broken when it is not. Fetch the rendered page over HTTP instead.

The settings screen looked correct throughout this bug, because it prefixes ../../ itself. The file was uploaded, the setting was right, and the single place it had to work was the only place that was wrong β€” a screenshot of the settings page cannot show that class of fault.

The filter

.portal-brand img carries filter: brightness(0) invert(1). Correct for the bundled mark β€” a single-colour logo drawn to sit on any header colour β€” and wrong for an uploaded one, which arrives as a white silhouette. .portal-brand.has-custom-logo img { filter: none; }.

The class is on the wrapper and the <img> is inside it, so the URL must be resolved above the div. Resolving it where the img is leaves the class reading an undefined variable: no error, and the class silently never applies.

Position

self_service_logo_position is header (default) or page, validated against SELF_SERVICE_LOGO_POSITIONS. An unrecognised stored value falls back to header, because a logo that renders nowhere reads as a broken upload rather than as a setting.

Three states are worth testing, and the third is the one that bites an install that never uploads anything: in the bar; on the page; and no custom logo with position page, where the bundled mark must stay in the bar and no empty band appear.

Colours

Hex validated both ways β€” on save and on read β€” because the value is written into a style attribute.

Patterns

SELF_SERVICE_PATTERNS is the allow-list, and the settings screen builds its options from it, so an option appears and disappears with the constant and there is no second list to keep in step. A stored pattern no longer in the list resolves to none.

dots, grid and diagonal are CSS gradients drawn in currentColor at ~2–3%.

flow and mesh are generated SVG files (scripts/gen_portal_flow.php), and are the exception to everything above:

  • An SVG referenced from background-image is a separate document: it cannot inherit currentColor, and CSS gives no opacity control over a background image. So each has a light and a dark file, with its strength baked in.
  • The maths is documented in the generator. In short: mesh is three ribbons, each rotated about its own centre (which is what makes them cross and read as depth), each a sum of two waves at 2Ο€ and 4Ο€ travelling in opposite directions (periods that do not divide evenly, so the shape never visibly repeats), with alpha by distance from the ribbon's spine (varying stroke width does not achieve this).
  • Size is the real constraint: the animated canvas version this came from draws 390 lines at 180 steps, which is free per frame and would be ~630KB as SVG. Integer coordinates and reduced sampling hold the density at 22KB and 75KB.

πŸ”΄ Bump self-service-patterns.css?v= whenever the patterns change. Softening them from 7% to 3% once reached nobody, because the version was set in an earlier commit and never moved. The change looked done and was not.


See also

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally