Brand kit
The brand kit holds what every document of your organisation shares: the company name, the legal footer, colours, fonts and logos. Templates read it instead of repeating it, so a new logo or a changed registered address reaches every document at once, published templates included, without saving a single new version.
Every organisation has exactly one brand kit, on every plan. It applies to all workspaces of the organisation. A new organisation's kit starts with the organisation's name as the company name and nothing else.
Set it up in the app
Open Brand in the organisation navigation. The page has one card per part of the kit:
| Card | What you set |
|---|---|
| Identity | Company name (up to 120 characters) and Legal footer (company, address, register number, VAT ID; up to 2,000 characters). Templates read them as brand.name and brand.legal_footer. |
| Logos | Three variants: Primary (the full logo on a light background), Inverse (for dark backgrounds) and Mark (only the symbol, for headers and small spaces). PNG, JPEG, WebP or SVG up to 512 KB. A logo is saved as soon as you drop or upload it, and removed with its own button, without Save brand kit. |
| Colours | Up to 24 named tokens, each with a colour: primary, accent, text, muted… A token is 1 to 32 lowercase letters, digits and dashes and starts with a letter; a colour is #rgb, #rrggbb or #rrggbbaa. Each token becomes brand.colors.<token> and the CSS variable --brand-color-<token>. |
| Typography | Heading font and Body font (a family name, up to 100 characters) and Base size, a number with pt or px (10pt, 14px). The font lists offer your organisation fonts first, then the fonts every render has built in (Noto Sans, Noto Serif, Liberation Sans, Liberation Serif, DejaVu Sans, DejaVu Serif); you can also type any other family name. |
| Preview | A sample letterhead with the unsaved values, rendered the way a template reads the kit. |
| Defaults for new templates | Paper format (A3, A4, A5, A6, Letter, Legal or Tabloid), Orientation (portrait or landscape) and the four margins as CSS lengths in mm, cm, in, px or pt (20mm). Each can stay Not set. |
Save brand kit stores everything except the logos, and the page warns you before you leave with unsaved changes.
Owners and admins edit the brand kit, logos included; editors and viewers see it read-only. Every change is recorded in the organisation's audit log.
Paper and margins are only a starting point: a new PDF template created with New template in the app begins with them. They are never applied when a template renders. A template stores its complete settings, so existing templates keep their own paper and margins when you change the defaults, and so do templates made from an example, an import or the CLI. GET /brand returns them as page_defaults.
The brand variable
Every template, in every language, sees the kit as brand:
{
"version": 7,
"name": "Fennlor Studio GmbH",
"colors": { "primary": "#0f766e", "accent": "#f59e0b", "text": "#111827" },
"fonts": { "heading": "Inter", "body": "Source Serif 4" },
"font_size": "10pt",
"logo": {
"primary": "https://cdn.formfeed.dev/a/brand/…/primary-3f9a1c2b7d4e.svg",
"inverse": null,
"mark": null
},
"legal_footer": "Fennlor Studio GmbH · Musterstraße 1 · 12345 Musterstadt · HRB 00000 · USt-IdNr. DE000000000"
}
| Field | Type | Meaning |
|---|---|---|
version | integer | Goes up by one with every change of the kit (logos included). A kit starts at 1; 0 means no kit at all, as in a local render without a pulled kit. |
name | string or null | Company name |
colors | object | One entry per colour token, { "primary": "#0f766e" }. Always an object, empty when there are no tokens. |
fonts.heading, fonts.body | string or null | The family names from Typography |
font_size | string or null | Base size, such as 10pt |
logo.primary, logo.inverse, logo.mark | URL or null | Public address of each logo on the CDN. A new file gets a new address, so no cache shows an old logo. |
legal_footer | string or null | Legal footer |
Values you have not set are null.
In Jinja2 and Liquid, brand is a global variable; in Handlebars it is added to the root of the data. The examples below read the same kit in each language:
- Jinja2
- Liquid
- Handlebars
<header style="border-bottom: 2px solid {{ brand.colors.primary }}">
{% if brand.logo.primary %}<img
src="{{ brand.logo.primary }}"
alt="{{ brand.name }}"
height="48"
/>{% endif %}
</header>
<h1 style="color: {{ brand.colors.primary | default('#111827') }}">
Invoice {{ invoice.number }}
</h1>
<header style="border-bottom: 2px solid {{ brand.colors.primary }}">
{% if brand.logo.primary %}<img
src="{{ brand.logo.primary }}"
alt="{{ brand.name }}"
height="48"
/>{% endif %}
</header>
<h1 style="color: {{ brand.colors.primary | default: '#111827' }}">
Invoice {{ invoice.number }}
</h1>
<header style="border-bottom: 2px solid {{brand.colors.primary}}">
{{#if brand.logo.primary}}<img src="{{brand.logo.primary}}" alt="{{brand.name}}" height="48">{{/if}}
</header>
<h1 style="color: {{default brand.colors.primary "#111827"}}">Invoice {{invoice.number}}</h1>
Inside {{#each}} and other blocks the context changes; reach the kit with {{@root.brand.name}}.
The legal footer belongs in the Footer tab, so Chromium prints it on every page:
<!-- Footer tab -->
<div style="width: 100%; font-size: 7pt; color: #666; text-align: center;">
{{ brand.legal_footer }} · Page <span class="pageNumber"></span> of
<span class="totalPages"></span>
</div>
({{brand.legal_footer}} in Handlebars.)
Your data wins. If the request data has a top-level field called brand, the template sees that field and not the kit. Templates that already read a brand from their data keep working; rename the field when you want the kit.
The editor offers brand.* in autocomplete, and brand is never reported as a variable missing from the sample data.
CSS variables
Formfeed also injects the kit as CSS custom properties, after the organisation fonts and before the print reset, so your CSS can use them and still override them:
:root {
--brand-color-primary: #0f766e; /* one per colour token: --brand-color-<token> */
--brand-color-accent: #f59e0b;
--brand-font-heading: 'Inter';
--brand-font-body: 'Source Serif 4';
--brand-font-size: 10pt;
}
Only values you have set produce a variable, so give a fallback where one may be missing:
body {
font-family: var(--brand-font-body, 'Helvetica Neue', Arial, sans-serif);
font-size: var(--brand-font-size, 10pt);
}
h1,
h2 {
font-family: var(--brand-font-heading, inherit);
color: var(--brand-color-primary, #111827);
}
.total {
background: var(--brand-color-accent);
}
With Tailwind use arbitrary values: class="bg-[var(--brand-color-primary)] text-white". They work in the editor preview and in the render alike.
Fonts. An organisation font named in the kit is embedded in every render that uses the kit, even when the template only says font-family: var(--brand-font-heading). A Google font or any other web font still needs its <link> in the Head tab; a family name alone does not load it.
Headers and footers
The CSS variables are also available in the header and footer templates, so inline styles such as style="color: var(--brand-color-primary)" work there.
Chromium does not load external images in headers and footers. Formfeed therefore embeds the brand logos for you: <img src="{{ brand.logo.mark }}"> in the Header tab prints the logo on every page. This applies to brand logo URLs only; any other image URL in a header stays as it is.
How changes reach your documents
- Live. The next render uses the current kit, for draft and published templates alike. There is no republishing.
- Within a minute. API renders read the kit through a cache of up to 60 seconds, so a change made in the app reaches them within a minute. The editor loads the current kit when you open a template, for the preview and for True render.
- Recorded. Every render records the brand kit version it used as
brand_versionin its reproducibility record, so you can tell later which kit a document was printed with. The render's details on the Renders page of the app show it under Reproducibility; the API's render object does not carry it (it hasengine_version,template_checksumandoutput_sha256, see Reproducibility). - Deduplication knows. A render is only answered from the deduplication cache when the brand kit version (and the shared partials) are the same as before; after a change the next request renders again.
- Never without it. If the kit cannot be read, the request fails with
500 worker_exhaustedbefore a render is recorded, so it costs nothing; retry it. A document is never printed without its legal footer.
Ad-hoc html renders that name an engine get the kit too, and so do Word and PowerPoint templates (which get no shared partials); plain HTML and url renders do not. How renders use them sums this up for the API.
Deleting a logo takes it out of the next render: brand.logo.<variant> becomes null. Guard <img> tags with an if as above so no broken image appears.
Read it from code
GET /brand returns the kit as templates see it, plus page_defaults and updated_at. The kit is edited in the app only; the API reads it.
Use it locally with the CLI
Local previews and renders do not call the API for the kit. Download it once into the project:
npx formfeed brand pull
# brand v7 (Fennlor Studio GmbH) -> .formfeed/brand.json
The file holds what GET /brand returns. formfeed dev, formfeed render (from local files), formfeed test, formfeed ci preview, formfeed renders pull --run and the @formfeed/testing helpers render with .formfeed/brand.json as brand; a running formfeed dev reloads when you pull again. formfeed validate checks that the file is valid JSON. Without the file, templates render with an empty kit: version 0, no colours and every other value null. That differs from your organisation's kit, which starts with the company name, so pull before you compare local output with the API's.
Pull again after changing the kit in the app. Whether you commit .formfeed/brand.json is up to you; committing it keeps snapshot tests stable in CI.