-
Notifications
You must be signed in to change notification settings - Fork 2
Brand Brand Guidelines
Cairn is an open source GRC (Governance, Risk, Compliance) platform built for CISOs, DPOs, auditors and compliance officers. This document defines the visual system of the product. It is meant for developers shipping CSS classes as much as for designers iterating on screens.
- About the name
- Logo
- Colors
- Typography
- Spacing, radii, shadows
- Iconography
- Components
- Forms
- Motion and transitions
- Accessibility
- Voice and tone
- Assets and files
Cairn, from Gaelic càrn: a deliberate stack of stones built to mark a safe path through the mountains. Where the trail becomes uncertain, the cairn reassures: someone has been here before you, you are on the right path.
The metaphor fits compliance. A CISO facing NIS 2, a DPO facing GDPR, an auditor facing ISO 27001 - all of them look for the cairns that mark an otherwise complex path. Cairn (the product) is that marking.
Positioning
- Audit-grade: gravitas, rigour, traceability. No mascot, no marketing gradient, no superlative.
- Open source by choice: readable code, paid-down design debt, no proprietary magic.
- Bilingual by construction: every label is translatable; the name reads
the same in French and English (
/kɛʁn/~/kɛərn/).
Pronunciation
- FR: kèrn, like the typography term "kern".
- EN: cairn, single syllable, silent
i.
The Cairn logo is a responsive two-variant system sharing a common bowl (the cup opening upward) but with an inner element that changes based on render size. Like a real cairn seen from far away (silhouette) and then up close (distinct stones).
| Variant | When | File |
|---|---|---|
| Primary - Stepped cairn | ≥ 24 px: sidebar brand, header, splash, high-res app icon | mark.svg |
| Small - Peak silhouette | ≤ 22 px: favicon, browser tab, low-res OS icon, dense lists | mark-sm.svg |
Rule: under 24 px, switch to
mark-sm. Above, stay onmark. 32 px belongs to the primary (where it starts to shine).
CAIRN in Inter 700, tight tracking at -0.04em, followed by a small navy
dot baseline-aligned - the silent signature of a validated stamp.
File: wordmark.svg.
Mark on the left, wordmark on the right, gap between them: 1.5x the
mark height.
╭───╮
│ ▲ │ CAIRN.
╰───╯
No graphic element shall enter a zone equivalent to 50% of the mark height around the logo. At a 32 px mark, that means 16 px of minimum margin all around.
- ❌ Do not reduce the primary variant under 24 px. Use
mark-sminstead. - ❌ Do not place the mark on a background that makes it unreadable (e.g. navy on navy). Prefer white, cream, or sufficiently contrasted ink.
- ❌ Do not invert the colours (the bowl stays white on navy, never the other way around).
- ❌ Do not change the internal proportions, do not stretch, do not rotate.
- ❌ Do not fill the bowl with another colour that would clash with the navy primary.
A single identity colour (navy), monochrome neutrals for everything else, and three semantic colours reserved for status only.
| Token | Hex | Usage |
|---|---|---|
--accent |
#1E3A8A |
Navy. The identity colour. Logo, active link, focus ring, primary CTA. |
--accent-hover |
#1E2E5E |
Deeper navy. hover / active state of primary. |
--accent-soft |
#EEF2FF |
Pale lavender. Active chip backgrounds, info alerts, subtle hovers. |
--accent-glow |
rgba(99,102,241,.18) |
Focus ring outline. |
| Token | Hex | Usage |
|---|---|---|
--bg-page |
#FAFAFA |
Application page background. |
--surface / --surface-raised
|
#FFFFFF |
Background of cards, modals, drawer, dropdowns. |
--surface-muted |
#FAFAFA |
Table row hover, secondary zone background. |
--surface-subtle |
#F5F5F5 |
Disabled state, visual separators. |
--text-primary |
#18181B |
Titles, values, main table text. |
--text-secondary |
#52525B |
Secondary labels, meta information. |
--text-muted |
#A1A1AA |
Placeholders, eyebrows, counters, ancillary info. |
--border-light |
rgba(0,0,0,.08) |
Card, input, sidebar borders. |
--border-subtle |
rgba(0,0,0,.05) |
Separators inside lists. |
--border-hairline |
rgba(0,0,0,.03) |
Intra-table lines. |
| Token | Hex | Usage |
|---|---|---|
--success |
#16A34A |
Approved, compliant, complete. Badge, alert, status icon. |
--success-soft |
#F0FDF4 |
Success alert / chip background. |
--warning |
#D97706 |
Pending, to review, partially compliant. |
--warning-soft |
#FFFBEB |
Warning alert / chip background. |
--danger |
#DC2626 |
Critical, non-compliant, expired, rejected. |
--danger-soft |
#FEF2F2 |
Danger alert / chip background. |
--info |
#0891B2 |
Neutral information. |
--info-soft |
#ECFEFF |
Info alert / chip background. |
Rule: no semantic colour is used as decoration. If an element is not a status, it takes navy or a neutral. The one sanctioned exception is an action button, where the semantic colour fills the button to signal the impact of the action itself (red = destructive, amber = caution, green = confirm, cyan = information) - see Buttons. Buttons are the only place a semantic colour appears as a solid fill.
Dark mode flips automatically via [data-bs-theme="dark"]. The rule:
warm-charcoal, never true black (#000), off-white text never pure white.
| Token | Hex |
|---|---|
--bg-page |
#0F1011 |
--surface |
#16181B |
--surface-raised |
#1C1F23 |
--text-primary |
#E4E4E7 |
--text-secondary |
#A1A1AA |
--accent |
#818CF8 (lighter indigo to carry on dark backgrounds) |
| Combination | Ratio | Compliance |
|---|---|---|
text-primary #18181B on bg-page #FAFAFA
|
16.6:1 | AAA |
text-secondary #52525B on surface #FFFFFF
|
7.9:1 | AAA |
text-muted #A1A1AA on surface #FFFFFF
|
2.85:1 | AA for large text only |
accent #1E3A8A on surface #FFFFFF
|
10.2:1 | AAA |
#FFFFFF on accent #1E3A8A (primary CTA) |
10.2:1 | AAA |
success #16A34A on surface #FFFFFF
|
3.4:1 | AA (UI), AA large text |
text-mutedmust never carry running text - it is reserved for labels, eyebrows and small annotations where the lightness is intentional.
One family: GitLab Sans. A single typeface across the whole product -
body, UI, labels, controls, table content, titles and emphasized values.
GitLab Sans is an Inter v4 derivative (it defaults
to the cv05 set, giving a distinct lowercase l), so it keeps Inter's
screen legibility while reading slightly more like a product face of its own.
Hierarchy comes from weight and size, not a second display font: there is
no separate accent family any more.
Licensed under OFL-1.1 and self-hosted (not a Google Font): loaded via
@font-face from the @gitlab/fonts package. The single variable .woff2
(weights 100-900) is currently served from jsDelivr; plan to vendor it into
static/fonts/ with a <link rel="preload"> for prod (perf + no third-party
dependency).
Exposed as tokens - never hardcode a family name in a component:
-
--font-sans- GitLab Sans stack (the default onbody). -
--font-display- GitLab Sans stack as well. The token is kept so a future accent face can be reintroduced in one place, but today both resolve to the same family.
font-synthesis: none is set globally so any single-weight fallback renders at
its true weight (no synthesized faux-bold).
Emphasis is carried by weight. GitLab Sans is variable, so non-standard weights (660, 810) are valid and used deliberately.
| Element | Family | Size | Weight | Tracking | Line-height |
|---|---|---|---|---|---|
h1 |
GitLab Sans | 1.75rem (28 px) | 700 | normal | 1.2 |
h2 |
GitLab Sans | 1.375rem (22 px) | 700 | normal | 1.25 |
h3 |
GitLab Sans | 1.125rem (18 px) | 600 | normal | 1.35 |
h4 |
GitLab Sans | 1rem (16 px) | 600 | normal | 1.4 |
h5 |
GitLab Sans | 0.9375rem (15 px) | 600 | normal | 1.4 |
h6 (eyebrow) |
GitLab Sans | 0.75rem (12 px) | 600 | +0.04em uppercase | 1.4 |
.display-4 / .display-6 / .text-display
|
GitLab Sans | 2.25rem (36 px) / … | 700 | normal | 1.1 |
.page-header__title |
GitLab Sans | 2.5rem (1.875rem ≤992px · 1.25rem compact) | 810 | normal | 1.12 |
| Sidebar app name | GitLab Sans | 1.25rem | 810 | normal | - |
| KPI / stat-card value · Overall compliance | GitLab Sans | 1.375-2.75rem | 900 | normal | 1-1.2 |
body |
GitLab Sans | 0.9375rem (15 px) | 400 | normal | 1.6 |
.small |
GitLab Sans | 0.8125rem (13 px) | 400 | normal | 1.6 |
.text-xs |
GitLab Sans | 0.75rem (12 px) | 400 | normal | 1.6 |
-
No UPPERCASE on titles. Reserved for eyebrows (h6 and
.form-label) as a discreet hierarchy cue. -
No negative tracking. Titles and KPI values use
letter-spacing: normal; emphasis comes from weight (up to 900), never from tight tracking. - Wide tracking on eyebrows / uppercase only. +0.04em to +0.08em on uppercase labels, badges and avatar initials - that's what creates the "small caps" effect. This is the only place positive tracking is allowed.
-
Tabular numbers everywhere numbers align vertically (numeric table
columns, KPIs). Utility class
.tabular-nums.
If the type system is ever revisited, evaluate candidates with Google Fonts' font matrix, which describes a face in three layers and pairs on the first two:
- Skeleton (form model) - Dynamic (humanist, diagonal axis, open apertures, warm), Rational (vertical axis, closed apertures, orderly), or Geometric (compass-built, no axis, clean but impersonal).
- Flesh - contrast level (linear ↔ contrasting) and serif presence.
- Skin - fine details (serif shapes, terminals); ignored for pairing.
Pairing rules: (1) same skeleton, different flesh = harmonious; (2) diagonal across the matrix (very different skeleton and flesh) = deliberate, strong contrast; (3) avoid different skeleton but same flesh (e.g. two sans of different construction) - it reads as dissonant.
Constraints for Cairn's body face: a Rational or Dynamic linear sans (never Geometric for running UI text), variable, OFL, full Latin + accents, good tabular figures. GitLab Sans (Rational, Inter-derived) satisfies all of these, which is why the product now runs on a single coherent family.
| Token | Value | Usage |
|---|---|---|
--radius-xs |
0.375rem (6 px) | Badges, small pills, ref pills. |
--radius-sm |
0.5rem (8 px) | Small buttons. |
--radius-md |
0.625rem (10 px) | Buttons, dropdowns. |
--radius-lg |
0.875rem (14 px) | Cards, sidebar, modals, drawer. |
--radius-xl |
1.125rem (18 px) | Hero, large-surface sections. |
--radius-2xl |
1.5rem (24 px) | Very rare, feature blocks. |
Soft depth, never hard offset-block shadows. Hierarchy comes from shadows AND spacing, not from thick borders.
| Token | Usage |
|---|---|
--shadow-xs |
Card at rest (very subtle). |
--shadow-sm |
Button bubble, light raised element. |
--shadow-md |
Card on hover, stat-card hover, dropdown menu, button hover, modal / drawer surface. |
--shadow-lg |
Command palette cards, heavy floating panels. |
--shadow-xl |
Element floating above everything. |
The app uses Bootstrap classes (mb-3, gap-2, g-4, ...) with the
adjustments defined in templates/base.html. In direct CSS, prefer rem
values consistent with the Bootstrap scale:
0.25rem / 0.5rem / 0.75rem / 1rem / 1.25rem / 1.5rem / 2rem / 2.5rem / 3rem
Bootstrap Icons 1.11.3 is the single library. No FontAwesome, no Lucide, no mixing.
- Always
<i class="bi bi-...">, and alwaysaria-hidden="true"when the icon is decorative (next to a text label). - Default size in em (the icon follows the parent text size). Explicit size when the icon is isolated: 1rem for a button, 1.25rem for a header button, 1.5rem for an empty state.
- Colour via
color: currentColorby default. Icons must never force an arbitrary colour.
| Concept | Icon | Code |
|---|---|---|
| Approved | check-circle | bi-check-circle |
| Pending | clock | bi-clock |
| Rejected | x-circle | bi-x-circle |
| Risk | exclamation-triangle | bi-exclamation-triangle |
| Essential asset | gem | bi-gem |
| Framework | journal-check | bi-journal-check |
| Search | search | bi-search |
| Plus / new | plus-lg | bi-plus-lg |
| Edit | pencil | bi-pencil |
| Delete | trash | bi-trash |
Shared primitives live in core/templatetags/ui.py and are documented
live on the /styleguide page (visit after login).
-
1pxborder--border-light, radius--radius-lg, no shadow by default. - Clickable cards: add
.card--linked. Hover lifts the card (-1px translateY +--shadow-md). - Padding
1.5rem 1.75rem.
- Rounded corners (
border-radius: var(--radius-md), 0.625rem),1pxborder, soft shadow (--shadow-sm) and the frosted chrome of the sidebar search field: abackdrop-filter: blur(8px)and a calm hover that lifts the shadow (--shadow-md). Every button reads as a member of the same glass family. -
Solid fills, never outlines / wireframes. Colour is carried by a
solid fill, not by a border + text colour. The fill + label colour of
each semantic variant is AA-tuned per theme through the
--btn-*-bg/--btn-*-fgtokens. -
Primary: solid navy (
--accent, the theme colour), white label. The main action of any screen. Keeps a hint of real frost (a slightly translucent fill). -
Neutral / secondary: the search field's light frosted glass
(
--glass, secondary text). The single style for every non-semantic action (Cancel, Back, Export, filters). Calm and quiet; the colour is reserved for primary and semantic actions. -
Danger: solid red (
--danger). Destructive actions (delete). -
Warning: solid amber (
--warning). -
Success: solid green (
--success). Approve / confirm. -
Info: solid cyan (
--info). - In tables, action buttons stay calm: borderless, no fill until hover, and the icon keeps the variant's colour (not the solid white label), so rows read as quiet icon affordances rather than blocks of colour.
-
.btn-linkremains a plain inline text link (no frosted chrome). - Bootstrap's
btn-outline-*class names are remapped in CSS to the same solid fill as theirbtn-*counterpart, so existing markup keeps working: authors may use either name interchangeably, but new markup should prefer the solidbtn-*names.
Sizing & placement. Default buttons (.btn) are the standard size;
use .btn-sm only for dense table rows and inline affordances. In modal
and confirmation footers, placement is standardized: the neutral
Cancel sits on the left (me-auto), the primary / destructive
action on the right. Cancel is always neutral (btn-secondary), never red.
{% page_header _("Risk register")
eyebrow=_("Risks")
icon="exclamation-triangle"
accent="risks" %}
<a href="..." class="btn btn-secondary">...</a>
<a href="..." class="btn btn-primary">
<i class="bi bi-plus-lg me-1" aria-hidden="true"></i>{% trans "New risk" %}
</a>
{% endpage_header %}- Without
accent: plain header, normal h1. - With
accent="<module>": a 2 px coloured line appears under the eyebrow. Available modules:risks,compliance,assets,context,reports,accounts,helpers,dashboard.
Inputs use a 1px border, radius 0.75rem, padding 0.5625rem 0.875rem.
Focus draws an accent ring (0 0 0 3px var(--accent-glow)) and switches
the border to --accent. Labels are 13 px, weight 600, in
--text-secondary.
For the doctrine, anatomy, steps, validation, deletion, accessibility and copy, see the dedicated Forms section below : it is the operational reference when building any create, edit or delete modal.
- Header transparent, weight 700, uppercase tracking
0.08em, bottom border1.5px subtle. - Row hover:
--surface-muted(very light). - Cell padding
0.75rem 1rem.
-
.badge-dot: pill with a small coloured dot prefix. Semantics via Bootstrap classes (text-bg-success, etc.). -
.ref: mono uppercase pill for reference codes (RISK-001). Background--surface-muted, border--border-light.
Forms are where audit-grade rigour meets day-to-day data entry. Every field reads like a contractual statement: the label is precise, the helper removes ambiguity, the error tells the user what to fix. Forms default to server-side validation and always preserve the user's input on error.
These seven rules govern every form in the product. They are not suggestions to weigh case by case : a screen that breaks one of them is a bug, not a variant. The aim is the 2026 baseline for data-entry surfaces - focused, single-task overlays that never make the user lose their place, never make them scroll to find the next field, and always tell them how far they have left to go.
| # | Principle | What it means concretely |
|---|---|---|
| 1 | One model, everywhere | All forms share the same shell, the same field anatomy, the same footer, the same motion. A user who has filled one form knows how every other form behaves. |
| 2 | Overlay, never a new page | A form opens as a modal layered above the dimmed current interface. It never navigates away. Closing it returns the user to the exact context (scroll position, filters, selection) they left. |
| 3 | Reusable by construction | The shell, the field partials and the step chrome are shared components. A new form composes them; it never re-implements layout, validation rendering or footer. |
| 4 | One form per action | Create and edit are distinct forms - distinct form classes, distinct endpoints, distinct modals, distinct titles. No single template branches on instance.pk to do both jobs. |
| 5 | No vertical scroll, visible progress | Every step fits within the viewport on a 13-inch laptop. A form too large for one step becomes a multi-step modal, and the user always sees where they are and what remains. |
| 6 | Required fields are unmistakable | Required fields are marked at the label and counted in the progress meter. The user never discovers a field was mandatory only after submitting. |
| 7 | Every field has a helper | Each data field carries a short, always-visible helper under the control. Guidance is never hidden behind a tooltip or an info icon. A self-evident adornment control (e.g. the icon picker, whose label and affordance already say what it is) may omit it. |
There is a single form surface : a modal layered over a scrim that dims the current interface. It is HTMX-driven, opened from a list row, a detail page or a header action, and it is the canonical shell for create, edit and confirm alike.
- The form never navigates. The URL behind it does not change to a full-page form route; the underlying screen stays mounted and dimmed.
- Closing (Escape, scrim click, Cancel) restores the exact context : scroll position, applied filters, table selection. The user resumes where they were, not on a reloaded list.
- Width follows content : a single-column modal for compact forms, a wider multi-step modal for rich objects. Never a two-column metadata sidebar - that pattern belonged to the page forms this doctrine replaces.
- The scrim is identical to the command palette / search overlay : a
page-tinted translucent veil (
color-mix(--bg-page 82%, transparent)) with a softblur(4px), never a dark dim. The modal surface itself carries the same hairline border and soft frosted shadow as the buttons (1px var(--border-light),--shadow-md), so it reads as a member of the same glass family. It enters with.fw-pop(honouringprefers-reduced-motion).
Canonical shell : templates/includes/modal_form.html.
Build every form by extending it and filling its field block. Do not
author bespoke form pages.
Migration note : the previous single-column / two-column page form patterns and the standalone delete page are retired. Existing page forms (e.g.
risk_form.html,issue_form.html) move into the modal shell as create / edit modals; their content is unchanged, only the container is.
A form is assembled from shared parts, never hand-built :
-
Shell :
modal_form.htmlprovides the scrim, the header (title + close), the step chrome, the body region and the sticky footer. -
Field : a single field partial renders label, control, helper and
error in the fixed anatomy order. Templates
iterate fields through it; they do not lay out
<label>/ control by hand. - Footer : the action cluster (primary + cancel) is one include with fixed order and styling.
If a form needs a layout the shared parts do not offer, extend the shared part - do not fork it into the template.
The single source of truth is the form. A form mixes in
core.modal_forms.SteppedFormMixin and declares steps; everything else
is derived.
| Piece | Where | Role |
|---|---|---|
SteppedFormMixin / Step
|
core/modal_forms.py |
Declarative steps, row layout, coverage + row-cap validation, is_stepped / is_multistep / modal_size / required_field_count / iter_steps(). |
| Shell | templates/includes/modal_form.html |
Modal chrome; emits data-mform, data-modal-size, novalidate (multi-step) and the progress / footer. |
| Field / row / progress |
includes/form_field.html, form_row.html, form_steps.html, form_progress.html
|
Auto-render of the steps. |
| Generic JS + CSS | templates/base.html |
Step navigation, completion meter, dialog sizing, focus, widget re-init, #hx-live error count. No per-entity JS. |
| Container |
#itemDrawer (in base.html) |
One centered Bootstrap modal, swap region #drawer-form-content, driven by the shared HtmxFormMixin (core/mixins.py). |
| Custom field widgets | context/widgets.py |
IconPickerWidget, ImageUploadWidget - reusable controls that auto-render in a step. |
Rules the engine enforces at instantiation: every visible field appears
in exactly one step; a step holds at most max_rows_per_step (default 7)
layout rows. modal_size is md single-step, lg multi-step.
A new entity form is therefore: a XBaseForm(SteppedFormMixin, …) with
steps + per-field help_texts, thin XCreateForm / XUpdateForm
subclasses, two view form_class lines + modal titles, and a template
reduced to the header icon. Nothing else.
Create and edit are separate forms, not one template toggled by a conditional.
- Distinct form classes (
RiskCreateForm,RiskUpdateForm) and distinct endpoints / modals. Each can carry the field set, defaults and helpers that its action actually needs. - Create starts empty, focuses the first field, and may hide audit fields that only exist after a first save (approval, history).
- Edit pre-fills from the instance, keeps audit-relevant fields visible (readonly where appropriate, never silently stripped), and never repeats the stored value in a placeholder or helper.
- Titles state the action and the object : New risk / Edit risk, never "Risk form" or "Create".
This costs a little duplication and buys clarity : each form does exactly one job, and neither carries dead branches for the other.
A form must never make the user scroll to reach a field or the submit button. Two mechanisms keep this true :
Fit, or split. A form whose fields fit one viewport is a single-step modal. A form with more fields becomes a multi-step modal : fields are grouped by meaning (see Field grouping) into steps, each step short enough to fit without scrolling. Never let a single step overflow - add a step instead.
Always show progress. The user always knows where they are and what remains :
- Multi-step : a step indicator sits in the modal header - numbered, labelled pills connected by a thin rule, with done / current / upcoming states (the same visual language as the workflow stepper). The current step is the navy pill; done steps carry a checkmark; upcoming steps are faded. Forward is gated on the current step's required fields being valid; backward is always free and never loses input.
- Single-step : a slim completion meter in the header counts required fields - "2 of 5 required" - filling toward navy as the user progresses. It is a calm progress signal, not a validation alarm : neutral until submit.
| Need | Mechanism | Treatment |
|---|---|---|
| Form fits one viewport | Completion meter | Header chip "N of M required", --accent fill, --surface-subtle track. |
| Form exceeds one viewport | Step indicator | Header stepper, pills navy / check / faded, thin connecting rule. |
Multi-step navigation lives outside the modal. A multi-step form has
no footer : navigation is two floating round arrows just outside the
dialog, vertically centred (.mform-nav, rendered once in base.html and
wired by the stepper JS). The left arrow goes back (hidden on the first
step); the right arrow advances and, on the last step, becomes a navy
check that submits. Clicking the check spins it in place while the
item saves. There is no Cancel button : cancellation is the header X or a
backdrop click. On narrow screens the arrows tuck into the lower corners
of the dialog. Single-step forms keep the standard sticky footer (Cancel
left, Save right).
When the active step changes, the modal morphs its height with a smooth
260 ms ease (a measure-swap-measure height tween on the modal content)
instead of snapping, so steps of different heights feel continuous.
prefers-reduced-motion skips the tween.
The four parts of any field, always in this order: label, control,
helper, error. Helper is mandatory on data fields : every such field
defines help_text and the partial renders it. A data field with no
helper is an unfinished field; only a self-evident adornment control
(e.g. the icon picker) may omit it.
<div class="mb-3">
<label for="{{ field.id_for_label }}" class="form-label">
{{ field.label }}{% if field.field.required %} *{% endif %}
</label>
{{ field }}
<div class="form-text">{{ field.help_text }}</div>
{% for error in field.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
</div>| Slot | Class | Spec |
|---|---|---|
| Label | .form-label |
13 px / 600 / --text-secondary. Trailing * on required (with a space before it). Never hidden, never replaced by a placeholder. |
| Control |
.form-control / .form-select / .form-check-input
|
15 px / 500 / --text-primary. 1 px border, radius 0.75rem, padding .5625rem .875rem. |
| Helper | .form-text |
13 px / 400 / --text-muted. Always present. Short, concrete, says what the value is for or how to fill it. No terminal punctuation unless it's a full sentence. |
| Error | .invalid-feedback.d-block |
13 px / 400 / --danger. Says what to fix, not just "invalid". Use .d-block so it appears unconditionally below the control. |
| State | Treatment |
|---|---|
| Default | Border --border-light, surface background. |
| Hover | Border darkens to rgba(22,19,18,.22) (subtle, not aggressive). |
| Focus | Border --accent, glow ring 0 0 0 3px var(--accent-glow). |
| Filled | No special styling: a value is its own signal. |
| Placeholder |
--text-muted at 70 % opacity, weight 400 (lighter than the typed value). |
| Disabled / readonly | Background --surface-muted, text --text-secondary. |
| Invalid | Add Bootstrap .is-invalid (red border). Error appears below as .invalid-feedback.d-block. |
Everything lives in one column, inside the modal body. There is no metadata sidebar and no full-width page grid : those belonged to the retired page forms.
For a form that fits one viewport : iterate fields through the field partial in a single column, show the completion meter in the header, and end with the sticky footer. This is the default.
{% extends "includes/modal_form.html" %}
{% block modal_form_title %}{% trans "New risk" %}{% endblock %}
{% block modal_form_fields %}
{% for field in form %}{% include "includes/form_field.html" %}{% endfor %}
{% endblock %}Submission is HTMX (hx-post on the form). The modal never closes on
validation failure : the partial returns with errors re-rendered and
the user keeps every value.
For a form too large for one viewport : split it into steps along the field grouping. Each step is one column of fields, short enough to fit without scrolling. The header carries the step indicator; the footer carries Back / Next (and Save on the last step). Step navigation is HTMX-driven and never loses input on either direction.
Order the steps so the most identifying fields come first (Identity), and the optional or relational ones come last - a user who abandons after step one has still given the essentials.
Two short, tightly related fields (e.g. type + category, or an icon
picker + a name) may share a row via a Bootstrap grid inside the step.
Never split a long textarea or a rich-text area : it takes the full
width. In the declarative engine this is expressed by nesting a list
inside the step's fields, with an optional width per cell ("auto" or a
1-12 Bootstrap span):
Step(_("Identity"), "diagram-3", [
[("icon", "auto"), "name"], # icon (narrow) + name (fills) on one row
"description", # full-width field
])Group fields by meaning, not by widget type. The grouping drives both the visual order within a step and the split into steps when a form goes multi-step. A group names one concept with an icon + a short title in the action-oriented voice of Voice and tone ("Analysis", not "Step 2"; "Identity", not "Basic info").
| Group | Typical fields |
|---|---|
| Identity | name, reference, type, category, scopes, owner |
| Analysis | levels, scores, descriptions, rationale |
| Status | workflow status, planned dates, assignees |
| Relations | linked assets, risks, frameworks, suppliers |
| Tags | free-form classification only |
Rules:
- One group, one step (when multi-step). A group with more than ~6 fields usually splits into two steps.
- A single-step form still orders its fields by these groups, top to bottom.
- Identity leads; Tags and Relations trail.
- Required fields carry a trailing
*next to the label (separated by a space). No extra helper text saying "Required" - the asterisk is the signal. - Don't mark optional fields ("optional" label, parentheses, etc.). If most of the form is optional, mark only the few required fields. If most fields are required, that's already the default expectation.
- The asterisk inherits the label's grey - it's a marker, not an alarm.
Avoid
<span class="text-danger">*</span>.
The actions live in the sticky modal footer, never inline among the fields.
| Position | Style | Action |
|---|---|---|
| Left | btn btn-primary |
Save (primary CTA). Carries <i class="bi bi-check-lg me-1"></i>. |
| Right | btn btn-outline-secondary |
Cancel (escape hatch). Always present; dismisses the modal and restores the underlying context. |
On a multi-step form, the footer carries Back (outline, far left) and Next (primary, right) on intermediate steps; the last step swaps Next for Save. Cancel is always reachable. Back never loses input.
Never put a destructive button beside Save. Delete, archive and reject
open their own confirmation modal (see Delete). They use
btn btn-danger.
The footer is pinned to the bottom of the modal with a soft gradient mask so the content fades into it rather than colliding. Because the form never scrolls past one step, the primary action is always in view.
Create and edit are two forms, not one template branching on
instance.pk (see One form per action). The
shell, the field anatomy and the footer are shared, so the two read
identically; only the form class, the endpoint and the title differ.
- Create starts empty, focuses the first field, and may omit audit fields that only exist after a first save (approval, history).
- Edit pre-fills from the
ModelForm. Never repeat a stored value in a placeholder or helper - the user already sees it in the control. - Audit-relevant fields (status, approval, history) stay visible in edit, readonly where appropriate. Never silently strip them.
- The modal title states the action and the object : New risk / Edit risk, never "Risk form" or "Create".
Deletion is a confirmation modal, layered over the dimmed interface like every other form - never a separate page. The canonical pattern:
{% extends "includes/modal_form.html" %}
{% block modal_form_title %}{% trans "Delete decision" %}{% endblock %}
{% block modal_form_fields %}
<p>
{% blocktrans with ref=object.reference title=object.title %}
Delete decision <strong>{{ ref }} - {{ title }}</strong>?
{% endblocktrans %}
</p>
<p class="text-muted">
{% trans "This action cannot be undone. The 4 linked actions will become orphaned." %}
</p>
{% endblock %}
{% block modal_form_actions %}
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash me-1" aria-hidden="true"></i>{% trans "Delete permanently" %}
</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% trans "Cancel" %}</button>
{% endblock %}Rules:
- Always name the object in the confirmation sentence (
Delete framework "ISO 27001"?), never a generic "this item". - Spell out the consequence: "This action cannot be undone", "12 linked risks will be unassigned", "The 3 child sites will move to no parent".
- Verb on the button: Delete, Archive, Remove, Reject. Never "OK", "Yes", "Confirm".
- Destructive primary button on the left, Cancel on the right - same order as Save / Cancel; muscle memory wins.
| Need | Widget | Notes |
|---|---|---|
| Short text |
<input type="text"> (.form-control) |
Default. |
| Long text |
<textarea> (.form-control) |
Auto-upgraded to pell rich text on page load (frosted glass field, Bootstrap-Icons toolbar, semantic attribute-free HTML). Stored content is sanitized with DOMPurify before being seeded into the editor. Opt out with .no-richtext. |
| Enumerated value |
<select> (.form-select) |
Auto-upgraded to TomSelect for searchable single-select where appropriate. |
| Tags | <select multiple name="tags"> |
Always TomSelect with the create plugin, initialised by base.html. |
| Date |
<input type="date"> (.form-control) |
Native picker; format YYYY-MM-DD. |
| Datetime | <input type="datetime-local"> |
Same conventions; UTC stored, local shown. |
| Boolean |
<input type="checkbox"> (.form-check-input) |
Wrap in .form-check with the label on the right. |
| Scope tree | Custom radio / checkbox tree | See templates/includes/scope_tree.html. |
| File upload | <input type="file"> |
The parent <form> must set enctype="multipart/form-data". |
Widget enhancements (TomSelect, pell) are wired centrally in
templates/base.html. Never load them
manually inside a template.
-
Server-side is the source of truth. Always validate in the view,
even when client-side hints exist. Browser
required/patternattributes are nice-to-have, not load-bearing. - Field-level errors are rendered as
.invalid-feedback.d-blockdirectly under the control. - Form-level errors (
form.non_field_errors) render as a single<div class="alert alert-danger">at the top of the form, before the first field. - HTMX submissions return the same partial with errors re-rendered; the modal stays open and the user keeps their input.
- On a multi-step form, a failed step keeps the user on that step with its errors shown; the step indicator marks the step as incomplete rather than done.
- The ARIA polite region
#hx-liveannounces the error count after each HTMX submit. Screen readers hear "3 fields need attention" without having to re-scan the form.
- Every control has an associated
<label>viafor/id_for_label. Never use a placeholder as a label. - Errors are programmatically associated with the field via Bootstrap's
.invalid-feedbackadjacency. When the field carries.is-invalid, the message is announced. - Tab order follows visual order. No
tabindexoverrides. - The first input of a fresh form receives focus on render (
autofocuson the first non-disabled field). - Date inputs use
type="date"so screen readers and mobile keyboards pick the right affordance. - Touch targets ≥ 36 px (current input height range is 36 to 42 px).
- Required state is conveyed both visually (trailing
*) and semantically (requiredattribute carried by the widget). - Disabled inputs stay reachable in the tab order; truly hidden inputs
use
display: noneandaria-hidden="true". - The modal traps focus while open and returns focus to the trigger when it closes. Escape and the scrim both dismiss it, equivalent to Cancel.
- The step indicator marks the current step with
aria-current="step"; the completion meter exposes its count viaaria-labelso the progress is heard, not only seen.
Form labels and helpers follow the Voice and tone section. Concrete rules for forms specifically:
| Bad | Good |
|---|---|
Name (label on a risk form) |
Risk name |
Required (help text) |
(just the trailing *) |
Invalid input (error) |
Use a date later than the start date. |
Please fill out this field. (browser default) |
Enter a name. |
Saving... (button while submitting) |
The button stays "Save"; the form-wide spinner via hx-disabled-elt carries the loading signal. |
Are you sure? (delete) |
Delete framework "ISO 27001"? 12 requirements and 4 assessments will become orphaned. |
OK (button) |
Save, Create the risk, Approve, Delete permanently
|
Intentional motion, never decorative. Always honours
prefers-reduced-motion (global guard in base.html).
| Token | Cubic-bezier | Usage |
|---|---|---|
--ease-out |
cubic-bezier(0.16, 1, 0.3, 1) |
Appearances, hovers, scrolls. The default. |
--ease-in-out |
cubic-bezier(0.65, 0, 0.35, 1) |
Theme / mode transitions. |
--transition-spring |
cubic-bezier(0.34, 1.56, 0.64, 1) |
Light attention pop-ins, very rare. |
| Token | Value | Usage |
|---|---|---|
--transition-fast |
150 ms | Colour on hover, focus rings. |
--transition-normal |
220 ms | Surface transitions, opacity. |
--transition-slow |
320 ms | Content slides (drawer, page fade-in). |
Defined in base.html, applied via utility classes.
| Class | Effect | Duration |
|---|---|---|
.fw-fade-in |
Opacity 0→1 | 220 ms |
.fw-slide-up |
Opacity 0→1 + 8 px translateY | 320 ms |
.fw-pop |
Opacity 0→1 + scale 0.96→1 | 350 ms (spring) |
.fw-stagger > * |
Slide-up with cascading delay (40 ms per child via --i) |
320 ms |
On page load, major elements (page-header, h1, h2, card, alert,
row, table-responsive, stat-card) automatically play fw-slide-up
with a slight stagger (40 / 80 / 120 / 160 ms) to give the impression of
"page being built" rather than abrupt appearance.
- No animation longer than 400 ms outside page transitions.
- No perpetual decorative animation (rotating loaders OK, but no ambient pulses running continuously).
-
prefers-reduced-motion: reducecollapses all animations and transitions to 0.001 ms (global guard in place).
The app targets WCAG 2.2 AA. See also /styleguide which documents the
criteria covered.
| Criterion | How | Level |
|---|---|---|
| 1.4.3 Minimum contrast | All palette text/bg pairs are AA, most AAA | AA |
| 2.4.7 Focus visible | Global :focus-visible rule, 2 px accent outline at min 3:1 |
AA |
| 2.3.3 Reduced motion | Global guard prefers-reduced-motion: reduce
|
AAA |
| 4.1.3 Status messages | ARIA polite region #hx-live announces every HTMX mutation |
AA |
| 4.1.2 Name, Role, Value | Command palette in combobox pattern (role, aria-controls, aria-activedescendant) | A |
| 2.4.4 Link purpose | Descriptive aria-label on every icon-only button |
A |
| 2.5.8 Target size | Icon-only buttons at 36×36 px, above the 24×24 minimum | AA |
- All labels are translatable (never hard-coded without
_()or{% trans %}). - Decorative icons carry
aria-hidden="true". - Icon-only buttons carry a descriptive
aria-label. - Interactive elements have a target size ≥ 24×24 px.
- Transitions vanish when the user requests
prefers-reduced-motion: reduce. - The app is fully usable from the keyboard (tab, shift+tab, escape, enter, cmd+k for the command palette).
Cairn speaks to professionals who know their craft. The tone is:
- Sober: no superlative ("awesome", "great", "best"), no emoji in the interface (they are fine in release notes and docs).
- Precise: "Risk assessment" rather than "assessment"; "approved by X on DD/MM/YYYY" rather than "validated".
- Action-oriented: buttons describe the action, not the state ("Create a risk" rather than "New").
- Bilingual by default: every string is translated to French and English. When in doubt, the French follows the ANSSI / CNIL / ISO vocabulary ("exigence", "périmètre", "partie intéressée").
- ❌ "Click here"
- ❌ "You have X errors" (prefer "X requirements non-compliant")
- ❌ Unnecessary anglicisms in French strings ("checker", "splitter", "ownership").
| Bad | Good |
|---|---|
| "Oops, something went wrong" | "The record failed to save. Check the highlighted fields." |
| "Validated ✓" | "Approved by Alice Dupont on 2026-05-30" |
| "Empty" | "No risk recorded yet. Open an assessment to create one." |
| "New" | "Create a requirement" |
-
mark.svg- primary mark (≥ 24 px) -
mark-sm.svg- small mark / favicon (≤ 22 px) -
wordmark.svg- CAIRN wordmark with signature dot
-
/styleguide(accessible after login) - renders all shared components in their variants. The runtime reference, updated automatically as components evolve.
- Palette, shadows, radii and transitions are defined in
templates/base.html(:rootblock and[data-bs-theme="dark"]). - Shared components (badge, page_header, empty_state, etc.):
core/templatetags/ui.py+templates/components/. - This guide:
docs/brand/brand-guidelines.md.
Any change to the palette, motion tokens or core components must:
- Be discussed and approved before execution (transverse impact).
- Update both this guide AND the
/styleguidepage. - Be entered in the
CHANGELOG.md.
Last updated: 2026-06-10. Cairn (formerly Fairway).
Built from docs/ at v0.36.0. Edits made here are overwritten by the next release : open a pull request against the source instead.
- Administration
- Ask Cairn
- Assets and suppliers
- Compliance
- The dashboard
- Finding your way
- Getting started
- Incidents
- How records move
- Organisational context
- Reports and management review
- Risks
- Trust Center
- Architecture
- Configuration
- Contributing
- The documentation system
- Installation
- Internationalisation
- Operations
- Release process
- Security
- Testing
- Adding an assistant provider
- Adding a dashboard widget
- Adding a domain entity
- Declaring a lifecycle
- Adding an MCP tool
- Adding a REST endpoint
- Adding a report
- Interface conventions
- Dashboard widgets
- Lifecycles
- MCP tools
- MCP tool parameters : Assets
- MCP tool parameters : Compliance
- MCP tool parameters : Governance and context
- MCP tool parameters : General
- MCP tool parameters : Incidents
- MCP tool parameters : Reports and management review
- MCP tool parameters : Risks
- MCP tool parameters : System and administration
- MCP tool parameters : Trust Center
- Management commands
- Models
- Permissions
- REST endpoints
- Environment variables
- MCP server
- REST API
- Assistant module (Ask Cairn)
- Module 0: User Management and Access Control
- Module 1: Context and Organization
- Module 2: Asset Management
- Module 3: Compliance
- Module 4: Risk Management
- Module 4 bis - EBIOS Risk Manager
- Module 5 : Trust Center
- Module 6 : Security Incident Management
- Management review : ISO 27001:2022 compliance (clause 9.3)