Files
Haven/docs/theme-authoring.md

20 KiB

Theme API v1 authoring reference

Haven themes are ordinary CSS files served by the Haven server. Theme API v1 defines the CSS variables and semantic layout regions that theme authors can rely on without depending on Haven's internal class names or DOM structure.

Theme API v1 is intentionally a CSS-only contract. It does not expose JavaScript helpers, events, component internals, or permission APIs. Structural changes that require moving elements or creating interactive controls belong in a plugin, not a theme.

Theme file format

A theme filename must end in .theme.css and should begin with a metadata comment:

/**
 * @name My Theme
 * @description A short description.
 * @author YourName
 * @version 1.0
 * @icon M
 * @haven-theme-api 1
 */

The theme list reads @name, @description, @author, @version, @icon, @section, and @haven-theme-api. @section Seasonal puts the theme in the picker's Seasonal section; any other @section name gets a section of its own, and a theme without one is listed under Community. The picker's preview card is drawn from the file's --bg-primary, --bg-secondary, --accent and --text-primary values when they are plain colours. Haven checks the API declaration before it injects the stylesheet. Older Haven versions ignore metadata they do not recognise. Declare each field on its own metadata line. Multiple @haven-theme-api declarations make the theme invalid.

Use a short text character or emoji for @icon. Do not put HTML in metadata.

Compatibility

Haven classifies each installed theme before loading it:

Status Meaning Loaded
Compatible Declares @haven-theme-api 1 Yes
Legacy Does not declare a Theme API Yes
Unsupported Declares another positive integer version No
Invalid Declares an empty, fractional, zero, negative, or non-numeric version No

Legacy themes remain enabled for backward compatibility. Declaring an API is recommended because it lets Haven stop a known-incompatible theme before the theme can hide or break recovery controls.

Incompatible themes remain visible in Plugins & Themes and in the admin theme list for diagnosis, but cannot be enabled, newly published, or selected as a new server default. If a selected theme disappears or becomes incompatible, Haven falls back to its built-in theme and updates the user's saved preference.

Haven caches the last server compatibility verdict so a previously verified file theme can still load before first paint. A theme with no cached verdict waits for /api/themes on its first load. The server verdict is refreshed when the theme list loads, and the server validates the file again whenever it serves the stylesheet. The cache is only a flash-prevention optimisation.

Installing and publishing

  1. Put the file in the server's themes/ directory.
  2. Restart Haven or refresh the Plugins & Themes list.
  3. Open the theme picker (the palette button in the sidebar). As an admin you see it under Available to publish.
  4. Switch Publish on under its card to add it to every user's theme picker. Settings, Admin, Branding, Custom Themes has the same switches.

A published file is a selectable base theme. Haven applies the built-in Haven theme as the stable layout base, then loads the selected file after the core stylesheet. A file that is installed but not published is only for people who manage the server: they can try it, or enable it per browser as an additive CSS tweak from Plugins & Themes. Members do not see it there or in the picker, and the server will not save it as a member's theme. A member who already uses a theme when it is unpublished keeps it until they pick another.

Themes are server-managed files. Haven does not download themes from arbitrary URLs or provide a per-user raw CSS editor.

themes/compact.theme.css is a shipped Theme API v1 example that uses only public tokens, page markers, and layout regions. It deliberately leaves message geometry to the user's Layout Density setting; structural compaction belongs in a plugin.

Previewing a theme without a server

public/theme-preview.html is a static copy of the app with sample content: a conversation with every kind of markdown, a picture, a poll, reactions and a reply, the member list, an open profile card, the Settings modal, and a forum channel. It loads Haven's real stylesheet, so a theme file applied to it looks the way it will in the app.

  1. Open public/theme-preview.html from a checkout in a browser, or in a live editor such as Phoenix Code that previews as you type.
  2. In the toolbar at the top right, type the path to your theme relative to the file (../themes/my.theme.css for a file in the repository's themes/ folder) and press Apply, or open the page as theme-preview.html?theme=../themes/my.theme.css.
  3. Chat and Forum switch the view; Settings modal and Profile card show or hide those.

The page is a snapshot, so a change to the app's markup reaches it the next time it is regenerated with scripts/theme-preview-capture.js; the file's header comment and the script say how. A running server also serves it at /theme-preview.html.

Scoping a theme

Both the login page and the main application expose the API version:

html[data-haven-theme-api="1"] {
  /* Theme API v1 rules */
}

Use the page marker when a rule belongs to only one page:

[data-haven-page="app"] {
  /* Main application only */
}

[data-haven-page="auth"] {
  /* Login and registration only */
}

Public design tokens

Override public tokens on :root. All tokens are optional; omitted values come from the Haven base theme.

Backgrounds

Token Purpose
--bg-primary Main content background
--bg-secondary Sidebars and primary panels
--bg-tertiary Nested panels and controls
--bg-hover Hovered controls and rows
--bg-active Selected controls and rows
--bg-input Text inputs and editors
--bg-card Cards, messages, and modal surfaces

Accent and text

Token Purpose
--accent Primary accent colour
--accent-hover Hovered accent controls
--accent-glow Accent shadow or glow colour
--accent-text Preferred foreground for core accent controls
--text-primary Primary text
--text-secondary Secondary text and timestamps
--text-muted Hints, placeholders, and quiet labels
--text-link Links

Always check the contrast between --accent and --accent-text. A light accent usually needs dark accent text. A few specialised media controls use a fixed foreground for compatibility with existing themes, so verify those too.

Borders and semantic states

Token Purpose
--border Default border
--border-light Stronger or raised border
--success Positive and connected states
--success-text Foreground on success-filled controls
--danger Destructive and error states
--danger-text Foreground on danger-filled controls
--warning Warning states
--warning-text Foreground on warning-filled controls
--led-on Connected presence indicator
--led-off Disconnected presence indicator
--led-glow Connected indicator glow

Semantic colours can also appear as text or icons on Haven surfaces. Check each state colour against your backgrounds as well as against its paired foreground.

Typography and geometry

Token Purpose
--font-main Main interface font stack
--font-mono Codes and technical values
--font-heading Headings and display labels
--radius Default radius
--radius-sm Compact control radius
--transition Standard transition timing
--sidebar-width Navigation sidebar width
--right-width Context sidebar width

Use rem for dimensions. Haven's interface zoom changes the root font size, so pixel-based shell dimensions do not scale with the rest of the interface.

Decorative tokens

Token Purpose
--msg-glow Optional message hover glow
--scanline Optional scanline overlay value

Internal values

Message geometry properties such as --msg-pad-x, --msg-pad-y, --msg-avatar, --msg-gap, and --msg-gutter are not part of Theme API v1. Haven changes them for density preferences and responsive breakpoints, and file themes load after those core rules. Treating them as unconditional theme tokens would override the user's density and mobile geometry. They may be formalised in a later API after the related state has a stable pre-paint contract.

Public layout regions

Select major areas through data-haven-region:

[data-haven-region="navigation-sidebar"] {
  background: var(--bg-secondary);
}

Application regions

Region Purpose
app-shell Primary application shell, including the status bar
workspace Main multi-column workspace
server-rail Server navigation rail
server-list Dynamic server list inside the server rail
navigation-sidebar Channels and direct-message sidebar
account Current account identity and account actions
sidebar-content Reorderable sidebar content
join-channel Join-channel section
create-channel Create-channel section
channels Channel list section
direct-messages Direct-message list section
sidebar-footer Pinned sidebar footer and controls
sidebar-actions Persistent sidebar action buttons
theme-picker Theme selector
main Main content column
channel-header Active channel header
welcome No-channel welcome state
message-area Active chat area
webcams Webcam viewer
screen-shares Screen-share viewer
music-player Listen Together player
pinned-messages Docked pinned-message panel
message-list Scrollable message list
composer Message composer
soundboard Docked soundboard panel
context-sidebar Voice and member context sidebar
search-results Docked search-results panel
voice-roster Current voice participant list
member-list Current channel member list
voice-settings Voice device and quality settings
voice-controls Active call controls
status-bar Debug and connection status bar
thread-panel Thread conversation panel
settings Settings surface

Authentication regions

Region Purpose
auth-shell Authentication page layout
auth-card Login and registration card
auth-header Authentication branding header
theme-picker Authentication theme selector

theme-picker exists on both pages, but only one page is loaded in a document.

Public layout state

Haven exposes three state attributes for themes and layout plugins:

Hook Values Purpose
data-haven-density on html compact, cozy, spacious The user's message-density choice
data-haven-layout-editing on html 1 while active, otherwise absent Mod Mode is editing the native layout
data-haven-layout-owner on html Plugin-defined owner ID, otherwise absent The structural layout plugin currently moving regions

The density attribute is applied before first paint. Message geometry properties remain internal; use the state only to adapt surrounding layout without overriding the user's density choice.

Interactive plugins can also listen for synchronous state changes:

document.addEventListener('haven:density-change', (event) => {
  console.log(event.detail.density);
});

document.addEventListener('haven:layout-editing', (event) => {
  if (event.detail.active) restoreNativeLayout();
  else reapplyLayout();
});

document.addEventListener('haven:layout-owner-change', (event) => {
  console.log(event.detail.owner);
});

haven:layout-editing fires before Mod Mode starts moving panels and after it has restored the saved native arrangement. Structural plugins should suspend on the first event and reapply only after the second. event.detail.owner identifies the owner that was active before editing. Haven reserves ownership for that plugin until the second event completes, so other layout plugins cannot take its place based on listener order.

Structural layout plugins must also acquire the shared owner before moving regions and release it after restoring them:

if (HavenApi.Layout.acquire('MyLayout')) {
  movePublicRegions();
}

// During stop(), below the desktop breakpoint, or before Mod Mode edits:
restorePublicRegions();
HavenApi.Layout.release('MyLayout');

acquire() returns false while another structural plugin owns the layout. Listen for haven:layout-owner-change to retry after that owner releases it. This prevents one plugin from recording another plugin's temporary DOM as the native restore position. HavenApi.Layout.owner exposes the current owner.

Appearing in the Layout picker

The Sidebar Layout picker in Settings, Layout lists Original (Haven's own layout) and every layout plugin on the server, one at a time. A plugin shows up there when:

  • its file is named <Name>Layout.plugin.js (the picker shows its @name, without a trailing "Layout");
  • its class has _engage() and _disengage(), which move and restore the regions;
  • it acquires the layout under its file's base name, for example HavenApi.Layout.acquire('MyLayout') for MyLayout.plugin.js;
  • it keeps its on/off as HavenApi.Data.save('MyLayout', 'layoutOn', '1' | '0') and reads it back on start(), as Braid and Compact do.

Picking another layout disengages the current one and saves layoutOn = '0' for it, so a plugin never has to know about the others.

Stability policy

For Theme API v1, Haven intends to keep these stable:

  • data-haven-theme-api="1"
  • data-haven-page values
  • documented data-haven-region values
  • documented data-haven-density, data-haven-layout-editing, and data-haven-layout-owner states
  • documented haven:density-change, haven:layout-editing, and haven:layout-owner-change events
  • HavenApi.Layout.acquire(), release(), and owner
  • documented public design tokens

The following are not part of Theme API v1:

  • element IDs and class names
  • exact DOM nesting or sibling order
  • inline styles used to represent runtime state
  • dynamically generated message, channel, member, and modal internals
  • undocumented custom properties
  • undocumented plugin APIs or JavaScript objects

A region keeps its semantic responsibility, but Haven may change its element type, class, ID, children, or location. Write selectors against the region and avoid depending on its internal descendants when possible.

Responsive layout

Haven's core breakpoints are:

  • above 900px: full desktop shell
  • 769px through 900px: tablet layout with an overlay context sidebar
  • 768px and below: mobile overlay navigation and context sidebars
  • 480px and below: phone sizing

Restrict deep desktop layout changes to the full desktop shell unless the theme also implements and tests the tablet and mobile states:

@media (min-width: 901px) {
  [data-haven-region="navigation-sidebar"] {
    width: 15rem;
  }

  [data-haven-region="context-sidebar"] {
    width: 15rem;
  }
}

Do not remove the mobile overlay controls solely because they are hidden on a desktop screenshot.

Assets and backgrounds

Assets placed beside a theme are served from /themes/. Use a relative path:

[data-haven-region="main"] {
  background-image:
    linear-gradient(rgba(0, 0, 0, 0.55), rgba(0, 0, 0, 0.55)),
    url("wallpaper.jpg");
  background-position: center;
  background-size: cover;
}

Keep assets small and provide enough contrast for text. Remote resources expose members' IP addresses to their hosts and may be blocked by Haven's security policy. Prefer files installed with the theme.

Motion and accessibility

Respect reduced-motion preferences:

@media (prefers-reduced-motion: reduce) {
  * {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    scroll-behavior: auto !important;
  }
}

Keep visible focus states, readable contrast, keyboard access, and touch target sizes. Hiding a duplicate-looking control is unsafe unless every function it provides remains reachable elsewhere.

Theme or plugin?

Use a theme for:

  • colour, typography, spacing, radii, and backgrounds
  • sizing major regions
  • styling or hiding optional decoration
  • reordering direct flex or grid children when behavior remains intact

Use a plugin for:

  • moving controls between different DOM parents
  • creating menus, buttons, or interactive state
  • preserving access to actions removed from persistent chrome
  • reacting to voice, channel, media, or permission changes
  • restoring changed DOM when the customization is disabled

CSS can visually position an element outside its parent, but that does not move its semantics, clipping context, keyboard order, or event assumptions. Prefer a reversible plugin when a layout requires a real structural move.

plugins/CompactLayout.plugin.js is the minimal bundled example. It moves only complete public regions, records their original parents and sibling positions, restores the native layout below the desktop breakpoint and during Mod Mode, and leaves message density under the user's control. Its Ctrl+Alt+C shortcut and sidebar button provide an immediate way back to the classic layout.

Security

Themes are trusted server-managed CSS. CSS cannot use Haven's JavaScript API, but it can hide or imitate interface elements and can request external assets. Only install themes from sources you trust.

Plugins have a different security model: they execute JavaScript in the Haven page and are fully trusted code. Theme API v1 does not make plugins safe or sandbox them.

Extension Safe Mode

If a theme hides controls or a plugin breaks startup, open Haven with:

/app.html?haven-safe-mode=1

The login page also accepts ?haven-safe-mode=1. Safe mode is stored only for the current browser tab, so it survives the login-to-app navigation without changing saved preferences.

While safe mode is active:

  • Haven uses its built-in base theme.
  • File themes and additive CSS tweaks are not injected.
  • Plugin files are not fetched, evaluated, or started.
  • Installed extensions are still listed from server-provided metadata.
  • Enabled extensions can be identified and disabled.

Open Settings, Plugins & Themes to either reset all theme/plugin choices or exit safe mode without changing them. Resetting keeps unrelated account and display preferences, updates the server-synchronised theme preference to Haven, and then reloads without the safe-mode query parameter. If the server cannot confirm the reset, extensions remain suppressed and the recovery notice offers a retry after the connection returns.

Minimal complete example

/**
 * @name Calm Slate
 * @description A small Theme API v1 example.
 * @author Example
 * @version 1.0
 * @icon S
 * @haven-theme-api 1
 */

:root {
  --bg-primary: #202225;
  --bg-secondary: #17191c;
  --bg-tertiary: #292c30;
  --bg-hover: #33373d;
  --bg-active: #3c4249;
  --bg-input: #111315;
  --bg-card: #24272b;
  --accent: #7aa2f7;
  --accent-hover: #8fb1fa;
  --accent-glow: rgba(122, 162, 247, 0.25);
  --accent-text: #101216;
  --text-primary: #f0f1f3;
  --text-secondary: #b3b7bd;
  --text-muted: #7f858e;
  --text-link: #8ab4f8;
  --border: #34383e;
  --border-light: #454a52;
}

@media (min-width: 901px) {
  [data-haven-region="navigation-sidebar"] {
    width: 15rem;
  }
}

For a commented template containing every public token, copy themes/custom.css.example.