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
- Put the file in the server's
themes/directory. - Restart Haven or refresh the Plugins & Themes list.
- Open the theme picker (the palette button in the sidebar). As an admin you see it under Available to publish.
- 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.
- Open
public/theme-preview.htmlfrom a checkout in a browser, or in a live editor such as Phoenix Code that previews as you type. - In the toolbar at the top right, type the path to your theme relative to
the file (
../themes/my.theme.cssfor a file in the repository'sthemes/folder) and press Apply, or open the page astheme-preview.html?theme=../themes/my.theme.css. - 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')forMyLayout.plugin.js; - it keeps its on/off as
HavenApi.Data.save('MyLayout', 'layoutOn', '1' | '0')and reads it back onstart(), 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-pagevalues- documented
data-haven-regionvalues - documented
data-haven-density,data-haven-layout-editing, anddata-haven-layout-ownerstates - documented
haven:density-change,haven:layout-editing, andhaven:layout-owner-changeevents HavenApi.Layout.acquire(),release(), andowner- 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 769pxthrough900px: tablet layout with an overlay context sidebar768pxand below: mobile overlay navigation and context sidebars480pxand 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.