Developers
Cascading style sheets
A page's styling comes from three places: the template's own stylesheets, the
shared stylesheets under core/assets, and whatever the components, modules
and plugins on the page push into the document. This chapter covers where each
of those lives, how a file is found, and what a template can do about it.
The reason to read it before writing CSS is order. Your rules and a component's rules end up in the same cascade with the same specificity more often than you would like, and which of them wins is decided by where you linked the file, not by anything in the file itself. Getting that wrong produces a template that works until a component pushes a stylesheet, and then does not.
The template's own stylesheets
Convention puts a template's CSS in a css directory at the top of the
template directory. Nothing enforces the convention and nothing is loaded
automatically: every stylesheet a template uses is linked by a layout, by
name. There is no main.css that the CMS picks up on its own. A new template
whose CSS never appears is usually a template that never linked it.
A layout links a stylesheet one of two ways, and the choice decides the cascade.
Directly in the markup, before <jdoc:include type="head" />, so that it
loads ahead of anything an extension queues:
<link rel="stylesheet" type="text/css" media="all"
href="<?php echo $this->baseurl; ?>/templates/<?php echo $this->template; ?>/css/index.css?v=<?php echo filemtime(__DIR__ . '/css/index.css'); ?>" />
Or through the document, in which case it lands inside the head block, after the component's stylesheets:
// Load base styles
$this->addStyleSheet($this->baseurl . '/templates/' . $this->template . '/css/index.css?v=' . filemtime(__DIR__ . '/css/index.css'));
// Load theme
$theme = $this->params->get('theme');
if ($theme == 'custom')
{
$color = $this->params->get('color');
$this->addStyleDeclaration(include_once __DIR__ . '/css/themes/custom.php');
}
Kimera and Lucent use the first form; Kameleon uses the second, which is why its own rules need enough specificity to win against a component's.
Use the first form unless you want to override component CSS wholesale. It is
what northgate does: brand styles load first, components layer on top, and
the handful of component rules Northgate needs to beat are handled with
output overrides rather than with a specificity war.
Two details of the link are load-bearing:
$this->baseurlis the URL prefix for the templates directory —/appwhen the template lives inapp/templates,/corewhen it ships. Never hardcode either. Anorthgatelayout with/core/templates/…in it linkskimera's stylesheet on every page and gives no error at all.?v=<?php echo filemtime(...); ?>is the cache-busting convention used by every shipped template and byHubzero\Document\Asset\File::link(). Use it. A stylesheet linked without it is cached by version-less URL and will not refresh for returning visitors after a deployment.
What each shipped template links
| Template | Layout | Stylesheets it links |
|---|---|---|
kimera |
index.php |
css/index.css, plus css/browser/ie9.css and css/browser/ie8.css in conditional comments, plus a style declaration built by css/theme.php |
component.php |
css/component.css, same browser files |
|
error.php |
css/error.css |
|
offline.php |
css/offline.css |
|
lucent |
index.php, error.php |
less/main.css — the compiled CSS sits beside its LESS sources, not in css/ |
component.php |
css/component.css |
|
welcome |
index.php |
css/normalize.min.css, css/main.css |
kameleon |
index.php |
css/index.css, css/browser/ie9.css, the custom theme declaration |
cpanel.php |
css/index.css, css/cpanel.css |
|
component.php |
css/component.css |
|
login.php |
css/login.css |
|
error.php |
css/error.css |
|
system |
error.php |
css/error.css |
offline.php |
css/general.css, css/offline.css |
|
login.php |
css/system.css |
|
help.php |
getSystemStylesheet(), css/help.css |
|
group.php |
getSystemStylesheet(), and the active template's css/main.css and css/group.css |
system/group.php is the super group layout, and it is the one place a
stylesheet is looked for in a template other than the one that owns the
layout. It asks the active template for css/main.css and css/group.css.
Kimera ships css/group.css but no css/main.css, so a super group page
under Kimera requests one stylesheet that does not exist. If you write a site
template that will be used with super groups, ship both files.
Theme files
css/theme.php in Kimera and css/themes/custom.php in Kameleon are PHP
files that return a CSS string built from the template's parameters. The
layout includes the file and passes the result to addStyleDeclaration(). See
Page layouts for how the parameters reach them.
This is the mechanism to copy for northgate's accent colour: one parameter in
the manifest, one css/theme.php that turns it into rules, and no second copy
of the template per institution.
Two things about the pattern are easy to get wrong, and Kimera shows both:
- The theme file reads variables out of the including scope.
kimera/css/theme.phpuses$bground,$color1,$color2,$opacityand$opacity2, whichindex.phpsets immediately before the include. Set them first or the file builds a stylesheet from nulls. - It is pulled in with
include_once, and it declares a function.include_oncereturns the string on the first include andtrueon any later one, which is whyindex.phpguards withif ($styles). Switching to a plainincludeto get the string twice fatals instead, on the redeclaration ofhex2rgb(). If you need the CSS in two layouts, put the builder in a function or a class and call it.
LESS
LESS is the preprocessor in use. There are two sets of sources.
core/assets/less/ holds the shared ones. site.less is the build file and
names everything in it:
// Core variables and mixins
@import "variables.less"; // Modify this for custom colors, font-sizes, etc
@import "mixins.less";
// CSS Reset
@import "reset.less";
// Fonts
@import "fontcons.less"; // Fontcons defines available icons
@import "icons.less"; // !! Dependant upon Fontcons.less
// Grid system and page structure
@import "grid.less";
@import "layout.less";
// Base CSS
//@import "type.less";
//@import "code.less";
//@import "forms.less";
//@import "tables.less";
// Components: Buttons & Alerts
@import "buttons.less";
@import "notifications.less";
// Components: Nav
@import "tabs.less";
//@import "navbar.less";
@import "pagination.less";
// Components: Popovers
@import "modal.less";
@import "tooltip.less";
//@import "popovers.less";
// Components: Misc
//@import "introduction.less";
@import "tags.less";
@import "voting.less";
@import "comments.less";
// Utility classes
@import "utilities.less"; // Has to be last to override when necessary
core/assets/less/variables.less holds the colours, font stacks and sizes
those files use; a template that imports the shared sources redefines the
variables it wants before the import. That is the whole of a re-brand, done
properly: northgate/less/_variables.less sets @linkColor and the font stack,
and the imports below it recompile against those values.
Each template that uses LESS keeps its own sources in a less directory and
imports across into core/assets/less by relative path. Kimera's
less/index.less opens this way:
// Core variables and mixins
@import "_variables.less"; // Modify this for custom colors, font-sizes, etc
@import "../../../../core/assets/less/mixins.less";
// CSS Reset
@import "../../../../core/assets/less/reset.less";
Those ../../../../ paths survive a copy into app/templates, because
app/templates/northgate/less/ sits the same depth below the repository root
as core/templates/kimera/less/.
The compiled result is what ships and what the layout links:
less/index.less → css/index.css for Kimera and Kameleon,
less/main.less → less/main.css for Lucent.
The shared stylesheets
core/assets/css/ holds the stylesheets shared by every extension: reset,
layout, columns, fontcons, icons, buttons, notifications,
pagination, tabs, tags, comments, voting, tooltip, introduction
and the third-party jQuery ones. Most are the compiled output of the matching
file in core/assets/less/.
Anything on the page can pull one in by naming system as the extension:
$this->css('introduction.css', 'system');
That resolves to core/assets/css/introduction.css, and it is overridable —
see Output overrides.
getSystemStylesheet
Hubzero\Document\Assets::getSystemStylesheet() returns the URL of the
compiled shared stylesheet. It compiles core/assets/less/site.less with
Hubzero\Document\Lessc
and writes the result to app/cache/{client}/site.css, minified unless
application_env is development. In production it serves the cached file
directly; otherwise it recompiles when any imported file has changed.
A template can take the build over. If the active template has a
less/site.less, that file is used as the build root instead of the core one,
and the template's less directory is put ahead of core/assets/less on the
import path, so @import "variables.less" inside it resolves to the
template's copy if there is one.
Call it with no arguments:
<link rel="stylesheet" type="text/css" media="screen"
href="<?php echo \Hubzero\Document\Assets::getSystemStylesheet(); ?>" />
Pushing CSS from an extension
Components, modules and plugins do not link stylesheets themselves; they queue
them on the document. There are two css() methods, and they differ in their
third argument.
From a view
Views get css() and js() from
Hubzero\View\Helper\Css.
Both return the view, so calls chain:
$this->css() // the extension's own stylesheet
->css('instruments') // the .css extension is optional
->css('tags', 'com_tags'); // from another component
With no arguments, the file taken is the extension's default name: for a
component the name minus com_ (com_bookings → bookings.css), for a module
the full directory name (mod_notices → mod_notices.css), for a plugin the
plugin's own name (plg_groups_forum → forum.css).
The arguments are (name, extension, element). element is for plugins, and
turns the first two into a plugin name:
$this->css('forum', 'groups', 'forum'); // plg_groups_forum
A string containing { or @ is treated as a declaration rather than a file
name and goes to addStyleDeclaration():
$this->css('.foo { color: #000; }');
From a controller, module or plugin
Hubzero\Component\SiteController (and so AdminController),
Hubzero\Module\Module and Hubzero\Plugin\Plugin get css(), js() and
img() from
Hubzero\Base\Traits\AssetAware:
public function css($stylesheet = '', $extension = null, $attributes = array())
{
$extension = $extension ?: $this->detectExtensionName();
$attr = array_merge(array(
'type' => 'text/css',
'media' => null,
'attribs' => array()
), $attributes);
$asset = new Stylesheet($extension, $stylesheet);
$asset = $this->isSuperGroupAsset($asset);
if ($asset->exists())
{
if ($asset->isDeclaration())
{
\App::get('document')->addStyleDeclaration($asset->contents());
}
else
{
\App::get('document')->addStyleSheet($asset->link(), $attr['type'], $attr['media'], $attr['attribs']);
}
}
return $this;
}
Where the file is looked for
Hubzero\Document\Asset\File::sourcePath()
builds the search list. For css('bookings', 'com_bookings') on the site it
tries, in order, under app/ and then under core/:
components/com_bookings/site/assets/css/bookings.css
components/com_bookings/site/css/bookings.css
components/bookings/site/assets/css/bookings.css
components/bookings/site/css/bookings.css
site there is the client name, so an admin view of the same component looks
under admin/ instead. Modules drop the client segment
(modules/mod_notices/assets/css/mod_notices.css) and plugins use the folder
and element (plugins/groups/forum/assets/css/forum.css). assets/css is the
directory to use for new work; the flatter variants are only there for old
extensions.
Prefixing the name changes the meaning: ./name.css looks for the file at the
root of the extension directory, and /name.css is an absolute path from the
web root. A name beginning http, // or :// is passed through as an
external URL.
Every one of these lookups checks the active template for an override first. That is the subject of the next chapter.
The static helpers
Hubzero\Document\Assets also carries the older static methods —
addComponentStylesheet(), addModuleStyleSheet(), addPluginStyleSheet()
and their Script counterparts. They wrap the same asset objects and honour
the same overrides:
Hubzero\Document\Assets::addComponentStylesheet('com_bookings');
Hubzero\Document\Assets::addModuleStyleSheet('mod_example');
Hubzero\Document\Assets::addPluginStyleSheet('groups', 'forum');
Prefer $this->css(). The statics remain for code that has no view,
controller, module or plugin object to hand.
Browsers
Every shipped site layout puts browser classes on the <html> element, from
Hubzero\Browser\Detector,
alongside the text direction and the template's own state:
$browser = new \Hubzero\Browser\Detector();
$cls = array('no-js', $browser->name(), $browser->name() . $browser->major(), $this->direction);
That gives selectors such as html.chrome, html.firefox52 and html.rtl to
hang a fix on, and it is the mechanism to reach for.
Kimera, Kameleon and Lucent also carry conditional comments loading
css/browser/ie8.css and css/browser/ie9.css. Conditional comments were
removed from Internet Explorer at version 10 and Internet Explorer itself is
out of support; the files are kept so existing installs do not change, and
there is no reason to add them to a new template.
Further reading
- JavaScript — the matching
js()calls and the script behaviours. - Output overrides — replacing an extension's stylesheet from the template.
- Elements and typography — the markup the shared stylesheets expect.
Rewritten and checked against 2.4-main @ 91d03d0a23 on 2026-09-10.