Developer Guide
GLL Component Library — Developer Guide
This document is written for human developers. It covers the full design system in a readable, connected way — the thinking behind decisions as well as the rules themselves.
If you're an AI agent generating or editing code, the numbered docs (01_overview, 02_colours, etc.) are structured for faster and more precise reference. Use those instead.
How the System Is Put Together
The component library is a Rails gem built on ViewComponent. Pages are assembled from a hierarchy of components:
- Documents (PageComponent, ArticleComponent) are the outermost shell. They compose a header, footer, page head, and a series of panels.
- Panels (HeroComponent, PatternComponent, CarouselComponent, etc.) are full-width page sections. Each is wrapped in a
<section class="panel">automatically by the base class. - Patterns (MediaCardComponent, TextCardComponent, ImageComponent, VideoComponent) are the reusable content blocks that live inside panels.
- Components (ButtonComponent, HyperLinkComponent) are atomic elements used inside patterns.
This hierarchy means you rarely need to think about composition from scratch — the correct level to work at is usually clear from what you're building.
Component Templates
All component templates are defined inline in their Ruby class using erb_template <<~ERB. There are no sidecar .html.erb files for components — if you're looking for a template and can't find an .erb file, look inside the .rb file.
The templates themselves are intentionally thin. Logic — generating HTML, building strings, computing class names — lives in private Ruby helper methods. Templates call those methods and slot methods; they don't contain conditionals more complex than a one-liner.
Slots
Components compose child components through ViewComponent's slot system. In templates, you always use with_* slot methods rather than calling render directly:
<%= with_cta %><%= with_hero_image %><% pattern_items.each do |pattern| %> <%= with_pattern(pattern:) %><% end %>This matters because slots can be overridden by parent components, and they document component dependencies clearly at the top of the class. Never use render directly in a template.
Colours
The colour system is built entirely on CSS custom properties. You will not find hex values in component stylesheets — if you need a colour, there is a token for it.
How the Palette Works
There are four colour families — primary, secondary, accent, and mono — each with a scale of tones from 10 (lightest) to 90 (darkest). You reference them as --primary-70, --accent-10, etc. Each family also has five named aliases:
| Alias | Tone |
|---|---|
light |
10 |
muted |
20 |
medium |
40 |
vibrant |
70 |
dark |
90 |
So --primary-vibrant is the same as --primary-70. Use the semantic alias unless you have a specific reason to use the tone number directly.
Semantic Tokens
For component work you'll almost always use semantic tokens rather than palette variables. Semantic tokens describe intent — --text-link, --headline-dark, --border-mono-medium — rather than a specific colour value. This means components automatically adapt when the palette is reconfigured for a different site.
The most commonly used tokens:
Backgrounds: --bg-primary-light, --bg-primary-dark, --bg-white, --bg-mono-light
Text: --text-dark, --text-light, --headline-dark, --headline-light, --text-link
Borders: --border-mono-medium (the standard one — used by @include card), --border-primary-dark
Links: --link-dark for links on light backgrounds, --link-light for links on dark backgrounds. --text-link is an alias for --link-dark.
The full token reference is in 02_colours.md.
Panel Backgrounds
Panels can have a background colour set via layout_background_colour. When a panel has a dark background, text and link colours inside it automatically switch to their light equivalents — you don't need to manage this yourself.
Typography
The type system is built on a named scale (XXL–XXS) defined in utilities/_mixins.scss — $type-scale for large screens and $type-scale-small for ≤1024px — and applied via the type-style() mixin, the single source of truth. The mixin is responsive: it emits the small-screen step automatically. Base elements are wired up in styles/_typography.scss. Font is Lato (with fallbacks to Arial/Helvetica/sans-serif), base body size 18px (XS), dropping to 16px below 1024px.
Heading sizes follow the scale from h1 (xxl, 48/56) down to h5 (s, 22/28). There's no custom heading component — use plain h1–h5 tags, either in ERB or via content_tag. To apply a scale step elsewhere, @include type-style(<token>) (e.g. @include type-style(s)); use type-size(<token>) instead when the element manages its own font-weight (buttons, links, table cells). See the Typography doc for the full table.
For body copy, a handful of utility classes handle the common size variations:
| Class | Size |
|---|---|
.medium-text |
18px (XS) |
.caption |
14px (XXS) |
.bold |
font-weight: 700 |
.italic |
font-style: italic |
Links globally get --text-link colour, a 2px underline, and 4px offset. On dark panel backgrounds this switches to --link-light automatically.
Spacing and Layout
The Grid
Panels use a 12-column grid on desktop, 4-column on mobile, with a 24px gap. You apply it directly in component templates with .row and .col-N classes:
<div class="row"> <div class="panel__body col-9"> ... </div> <div class="panel__media col-3"> ... </div></div>The .container class sets the max-width to 1128px on desktop and goes full-width on mobile. Panels get this automatically from the base class.
Responsive Mixins
Never write raw @media queries. The project provides named mixins that match the design's breakpoints:
| Mixin | Range |
|---|---|
@include mobile |
max-width: 540px |
@include mobiletablet |
max-width: 1024px |
@include tablet |
541–1024px |
@include tabletdesktop |
min-width: 541px |
@include desktop |
min-width: 1025px |
@include fullwidth_desktop |
min-width: 1400px |
@include mobiletablet is by far the most common — most mobile overrides use it. The distinction between mobile and mobiletablet only matters when you need to treat phones and tablets differently.
Panel Spacing
Panels have standard top/bottom padding baked in — 80px on desktop, 48px top/bottom and 24px sides on mobile. This comes from the base class. The gap between patterns inside a panel is 24px.
Spacing Values
All spacing is in px. Common values used throughout the system: 4, 8, 12, 16, 24, 32, 36, 40, 48, 80. There are padding utility classes .p-4 through .p-36 for one-off needs.
Flex Mixins
Rather than writing display: flex; flex-direction: column; gap: 16px by hand, use the project mixins:
@include flexcol16 // column, 16px gap — most common@include flexcol8 // column, 8px gap@include flexrow24 // row, 24px gap@include flexcol($gap) // column, custom gap@include flexrow($gap) // row, custom gapThe fixed-gap variants also collapse empty child elements, which prevents gaps appearing where content is absent.
How Components Express Visual Variants
This is probably the biggest architectural decision in the CSS, and it's worth understanding properly.
No BEM Modifier Classes
In a typical BEM codebase you'd write classes like .media-card--large or .panel--dark to express variants. This project doesn't do that. Instead, variants are expressed through data-* attributes set on the component wrapper:
.media-card[data-media-size=large] { ... }.panel[data-bgc=primary-dark] { ... }.text-card[data-layout-style=plain-text] { ... }The reason is that these attributes come directly from the Ruby data model, via panel_data_opts or pattern_data_opts in the component class. The CSS variant and the underlying data are always in sync, because the same value that drives the data also drives the styling.
You don't write these attributes in templates — the base class handles it. You define them in the component's data opts method:
def panel_data_opts { bgc: layout_background_colour, panel_style: "hero" }endWhen to Use :has()
The :has() pseudo-class is used for structural conditions — things the CSS needs to respond to that can't be captured in a data-* attribute because they depend on what's actually in the DOM at render time:
// Only add padding when the header actually has content&:has(> h1:not(:empty)) { padding-bottom: 40px; }// Rotate the arrow when the sibling submenu is open&:has(+ .open) { transform: rotate(180deg); }The guideline is: if the condition is known in Ruby at render time, use a data-* attribute. If the condition depends on DOM structure or runtime state, :has() is appropriate. One thing to avoid is using :has() to detect third-party class names (like icon library classes) — if those change, the styles break with no obvious connection.
State Classes
JS controllers toggle classes on elements to express runtime state. The classes the CSS responds to are:
| Class | Meaning |
|---|---|
.open |
Element is expanded |
.rotate |
Arrow/icon is in rotated state |
.loaded |
Icon font has loaded |
.disabled |
Element is non-interactive |
Interactive Behaviour
CSS Before JS
Before writing a Stimulus controller, ask whether CSS can handle it. Hover states, focus styles, and show/hide based on a toggled class all work without any JS. The footer's mobile accordion, for example, works by toggling .open on the submenu — the controller handles the toggle, and CSS handles everything visual with max-height: 0 / max-height: 2000px.
Reach for a controller when you need to measure the DOM, read runtime attribute values, respond to events CSS can't handle, or coordinate between elements that aren't structurally related.
How Controllers Work
Controllers are Stimulus controllers, living in app/javascript/gll_component_library/controllers/. They're wired to HTML via data-controller, and their methods are called via data-action. Each controller is a single file named in snakecase — `opentoggle_controller.jsmaps todata-controller="open-toggle"`.
The standard structure:
import { Controller } from "@hotwired/stimulus"// Connects to data-controller="my-thing"export default class extends Controller { static targets = ["target"] connect() { } // one-time setup disconnect() { } // cleanup if needed myAction() { } // called via data-action}Controllers are small. The simplest one in the codebase (open_toggle_controller.js) is ten lines. If a controller is getting complex, it's usually a sign some of the logic belongs elsewhere.
Keeping JS and CSS in Sync
Two conventions ensure JS and CSS stay aligned:
Breakpoint value: when checking viewport width in JS, use 1024 — the same threshold as @include mobiletablet.
CSS custom properties: when a JS measurement needs to influence layout, set it as a CSS custom property rather than applying inline styles directly:
this.element.style.setProperty('--width', `${this.logoTarget.offsetWidth}px`)The stylesheet then reads var(--width). This keeps style rules in CSS where they belong.
CSS Conventions
File Structure
Stylesheets mirror the component hierarchy:
styles/ Global base (colours, typography, grid, forms)utilities/ Mixins — _mixins.scss is the main filepanels/ One file per panelpatterns/ One file per patterncomponents/ Buttons, chips, tables, etc.Every partial starts with @use "../utilities/" as *; which makes all mixins available without a prefix.
The Card Mixin
Any bordered card container uses @include card, which gives consistent border-radius (8px), border (2px solid --border-mono-medium), and padding (24px). Don't write these values by hand.
Adding New Mixins
If you find yourself writing the same CSS in more than a couple of places, add a mixin to _mixins.scss. The flex mixins (flexcol16, flexrow24 etc.) are the model for this — common patterns extracted once so they're consistent across every component that uses them.
Nesting
Keep nesting to 2–3 levels. 4 is the absolute maximum. If you're going deeper, the selector structure probably needs rethinking.
Adding a New Component
A full step-by-step guide is in 05_adding_components.md. In brief:
- Choose the right level — Document, Panel, Pattern, or Component. Most new work is a Pattern or Panel.
- Create the Ruby class — inherit from the appropriate base class, use
delegateto expose data attributes, define slots withrenders_one/renders_many, write the template inline witherb_template. - Register it — if it's a Pattern, add it to
Patterns::COMPONENTSinpatterns/base_component.rb. - Write the SCSS — one file in the appropriate subfolder, following the BEM and data attribute conventions above.
- Add a Lookbook preview — so it's visible and testable in the browser at
/lookbook.