Brand
Norq’s brand system is a single brand.yaml file at your project root. It owns every visual choice – colors, typography, spacing, radii, named styles, fonts, voice – so notification authors reference tokens by name ({colors.brand}, {spacing.md}, {.button-primary}) instead of hardcoding hex codes.
The on-disk shape is a documented subset of the W3C Design Tokens Community Group spec (DTCG 2025.10), so you can import from / export to Figma Tokens Studio, Style Dictionary, and DTCG-aware tools without losing fidelity.
Quickstart
norq brand init # scaffold brand.yaml from compiled-in defaults
norq brand show # print the resolved brand (aliases + pipes flattened)
norq brand sync-agents # regenerate AGENTS_BRAND.md (AI agent context)Edit brand.yaml, run norq brand show to verify, then norq lint to catch issues. norq doctor surfaces a “Brand” section in its summary that combines lint counts + AGENTS_BRAND.md staleness.
File structure
# brand.yaml
$schema: https://norq.dev/schemas/norq-brand.schema.json
meta:
name: Acme
tokens:
colors:
brand: "#6d28d9"
brand-text: "#ffffff"
brand-hover: "{colors.brand | darken 10%}"
body: "#374151"
background: "#ffffff"
typography:
body:
fontFamily: "{fonts.sans}"
fontSize: 16
lineHeight: 1.6
heading-1:
basedOn: body
fontSize: 36
fontWeight: 700
lineHeight: 1.1
spacing:
sm: 8
md: 16
lg: 24
radii:
sm: 4
full: 9999
codeTheme: base16-ocean.dark
styles:
button-primary:
bg: "{colors.brand}"
color: "{colors.brand-text}"
radius: "{radii.sm}"
defaults:
image: { padding: 0 }
heading: { color: "{colors.brand}" }
element-typography:
h1: heading-1
p: body
fonts:
sans:
family: Inter
weights: [400, 700]
source: google
modes:
colorMode: auto
dark:
colors:
brand: "#a78bfa"
voice:
description: Warm, decisive, restrained.
principles:
- Use the brand color for the single most important CTA per email
- Maintain WCAG AA contrast (4.5:1 for normal text)Where the file lives
By default the loader looks for ./brand.yaml next to your norq.config.yaml. To put it elsewhere, set the brand: key in your config:
# norq.config.yaml
notifications: ./notifications
brand: ./design/brand.yaml # subdirectory
# brand: ../../packages/shared-brand/brand.yaml # monorepo sibling
# brand: node_modules/@acme/brand/brand.yaml # npm package
# brand: $BRAND_DIR/brand.yaml # env var expansionIf neither path resolves, Norq silently falls back to compiled-in defaults and prints an info diagnostic.
Tokens
Five sections, all flat (no nested groups):
| Section | What it holds | Sugar |
|---|---|---|
colors |
sRGB hex strings, {ref}s, or piped expressions |
– |
typography |
composite tokens with fontFamily / fontSize / fontWeight / lineHeight / letterSpacing |
basedOn: for token-level inheritance |
spacing |
dimensions (px / rem) |
bare numbers → px |
radii |
dimensions (px / rem) |
bare numbers → px |
codeTheme |
string name of a syntect TextMate theme | – |
Colors
tokens:
colors:
brand: "#6d28d9" # literal sRGB hex
primary: "{colors.brand}" # alias
brand-hover: "{colors.brand | darken 10%}"
muted: "{colors.body | alpha 0.6}"v1 accepts only sRGB hex strings. #RRGGBB, #RRGGBBAA, #RGB, and #RGBA all parse. Wide-gamut authoring (display-p3, oklch, lab) is a v1.x follow-up; on import from DTCG-encoded sources, wide-gamut values are preserved via $extensions for round-trip but not used at compile time.
Color pipes
Five pipes are supported in v1:
| Pipe | Args | Example |
|---|---|---|
darken <pct> |
percentage | {colors.brand | darken 10%} |
lighten <pct> |
percentage | {colors.brand | lighten 20%} |
alpha <0..1> |
float in [0, 1] | {colors.body | alpha 0.6} (emits rgba(...)) |
mix <hex> <pct> |
hex + percentage | {colors.brand | mix #ffffff 20%} |
contrast <target> |
hex | {colors.body | contrast #000} (picks one of two colors based on contrast against target) |
saturate, desaturate, hue-rotate, and arithmetic on dimensions are deferred (v1.1+).
Typography
tokens:
typography:
body:
fontFamily: "{fonts.sans}"
fontSize: 16
lineHeight: 1.6
heading-1:
basedOn: body
fontSize: 36
fontWeight: 700
lineHeight: 1.1basedOn: is Norq-specific token-level inheritance. The child deep-merges atop the parent (child wins per field). Cycles are detected and flagged as brand/cyclic-ref.
basedOn:is not DTCG$extends– DTCG$extendsis a group-level feature with different semantics. The keyword difference is intentional.
fontFamily accepts a literal CSS font-family string OR a {fonts.X} reference. The reference form picks up fonts.X.family from the fonts: block, so font delivery and typography stay in sync.
lineHeight is multiplier-only in v1. DTCG dimension form is coerced to a multiplier with a warning during DTCG import.
Spacing and radii
tokens:
spacing:
xs: 4 # bare number → px
sm: "8px" # explicit px
md: 16
lg: "1rem" # explicit rem
radii:
none: 0
sm: 4
full: 9999em and % are rejected at parse time. Bare numbers are sugar for px.
Aliases and reference resolution
Any string token value of the form {section.name} is an alias. The resolver:
- Looks up
section.namein the parsed brand. - Recursively resolves the target’s own
rawvalue (so chains likea → b → c → "#xxx"collapse to a single literal). - Detects cycles (
brand/cyclic-ref) and broken refs (brand/broken-ref).
Cross-section refs are allowed where they make semantic sense: typography.body.fontFamily: "{fonts.sans}" resolves to fonts.sans.family. dimension aliases stay within spacing ↔ radii.
Named styles
styles:
button-primary:
bg: "{colors.brand}"
color: "{colors.brand-text}"
radius: "{radii.sm}"
hero-headline:
typography: "{typography.heading-1}"
color: "{colors.heading}"Apply to a block element via the {.name} syntax:
[Shop now](https://acme.com){.button-primary}
# Welcome{.hero-headline}Multiple styles compose left-to-right; inline attrs override the bundle.
Cascade order
built-in compiler defaults
← brand.yaml defaults.<element-type>
← brand.yaml element-typography.<tag> (typography-only)
← brand.yaml styles.<bundle> (via {.name} on element)
← template frontmatter `brand:` block (partial override of anything above)
← inline template attrs
Later rows win.
Per-element defaults
defaults:
image: { padding: 0 }
button: { radius: "{radii.sm}" }
heading: { color: "{colors.brand}" }Recognised element types: image, button, heading, text, divider, section. Anything else fires brand/unknown-element-type (warning).
Heading → typography mapping
element-typography:
h1: heading-1
h2: heading-2
h3: heading-3
h4: heading-4
p: bodyThis mapping is stable per project – it can’t be overridden in a per-template frontmatter brand: block. Letting one template remap h1 → heading-3 is an accident, not a feature.
Fonts
fonts:
sans:
family: Inter
weights: [400, 700]
source: google
display:
family: "Cal Sans"
weights: [600, 700]
source: url
url: https://cdn.acme.com/fonts/cal-sans.css
serif:
family: Charter
source: system # no @import; assume the OS has itsource values: google (default), system, url (requires url:), bunny (Bunny Fonts CDN).
Set inject: false if your delivery pipeline already loads the font and you don’t want the email compiler emitting a <link> tag.
A typography token references a font by {fonts.X} (resolves to fonts.X.family):
typography:
body:
fontFamily: "{fonts.sans}" # → "Inter"If the font block is missing, lint fires brand/font-not-delivered (warning).
Modes
modes:
colorMode: auto # light | dark | auto
dark:
colors:
brand: "#a78bfa"
heading: "#e0e7ff"
body: "#d1d5db"
background: "#0f172a"
content: "#1e293b"auto emits @media (prefers-color-scheme: dark) CSS in the rendered email using the dark.colors overrides. dark forces a static dark render. light (default) skips the media query.
Only the email channel honours dark-mode overrides today. Other channels ignore them.
Voice
voice:
description: Warm, decisive, restrained.
principles:
- Use the brand color for the single most important CTA per email
- Maintain WCAG AA contrast (4.5:1 for normal text)
- Imperative mood; lead with what changedFree-form prose flows into the generated AGENTS_BRAND.md (regenerate with norq brand sync-agents). Recognised-rule registry that toggles additional lints from principles is a v1.1 follow-up.
Using brand tokens in template attrs
Inline bg= and color= attrs accept three forms:
| Form | Example | Resolves to |
|---|---|---|
| Brand color name | color="brand", color="secondary-text", bg="card" |
Looks up brand.tokens.colors.<name> and uses its resolved hex. |
| Hex literal | color="#6d28d9" |
Passed through verbatim. |
color= and bg= resolve in two tiers: literal #hex first, then any name in brand.tokens.colors. Unknown values fail with template/unknown-color-token / template/unknown-bg-token (severity Error).
::: highlight {bg="background" color="body"}
A subdued highlight using brand-defined colors.
:::
::: hero {bg="brand"}
[Get started](https://acme.com){.button-primary}
:::Unknown values fire template/unknown-color-token / template/unknown-bg-token (Error) — silent failures used to render as empty backgrounds; now you get a diagnostic listing every valid option for your project.
The full
{colors.X}brace-syntax used insidebrand.yamlitself is not yet honoured in template attr values. Use the bare name form (bg="secondary") instead. Brace-syntax in templates is a v1.1 follow-up.
Per-template overrides
Individual templates can override any piece of brand.yaml by adding a brand: block in their frontmatter:
---
subject: "Flash sale — 48 hours"
brand:
colors:
brand: "#ff4500" # campaign orange
background: "#fff7ed"
styles:
button-primary: # full pill for this campaign
bg: "{colors.brand}"
color: "#ffffff"
radius: "{radii.full}"
defaults:
image: { padding: 16 }
---
# Flash sale{.hero-headline}
[Shop now](https://acme.com/sale){.button-primary}{colors.brand} inside the override block resolves to #ff4500 (the override), not the project-level value. Standard rule: overrides are merged first, then references resolve against the merged brand.
What’s overridable: tokens.colors, tokens.typography, tokens.spacing, tokens.radii, tokens.codeTheme, styles, defaults.
What’s NOT overridable: element-typography (project-stable), modes.dark (project-wide), fonts (delivery concern), voice, meta.
Import / export
norq brand import DESIGN.md # convert from DESIGN.md
norq brand import tokens.json # auto-detects DTCG / Figma / Style Dictionary
norq brand export --format dtcg # canonical DTCG 2025.10 JSON
norq brand export --format tailwind # tailwind.config.js stub
norq brand export --format css # :root { --colors-brand: #...; }Pipes are pre-resolved before export – DTCG has no concept of computed tokens, so the exported file is a snapshot. Norq-specific bits (styles, voice, fonts, modes) ride along under $extensions.norq for lossless round-trip imports.
See norq brand for the full CLI surface.
Lints
Brand lints run once per project and attach diagnostics to brand.yaml. Full reference: Lint Rules.
| Code | Severity | Description |
|---|---|---|
brand/broken-ref |
error | A {path} alias or basedOn: target doesn’t exist. |
brand/cyclic-ref |
error | Token alias chain or basedOn: chain forms a cycle. |
brand/unknown-pipe |
error | Color expression uses an unsupported pipe. |
brand/invalid-pipe-arg |
error | Pipe receives the wrong arg shape. |
brand/bad-color |
error | Color value is not a parseable sRGB hex. |
brand/invalid-dimension |
error | Dimension uses a unit other than px / rem. |
brand/missing-primary |
warn | No colors.brand token. |
brand/missing-typography |
warn | An element-typography.<tag> mapping points at undefined typography. |
brand/font-not-delivered |
warn | A typography fontFamily references {fonts.X} but the block is missing. |
brand/orphaned-token |
warn | A custom token is defined but unreferenced. The 17 EmailTheme color names + body/code typography are exempt. |
brand/unknown-element-type |
warn | defaults.<x> for an element the compiler doesn’t recognise. |
email/low-contrast also walks brand.styles.* bundles that declare both bg and color and resolves them through the brand before the WCAG-AA check.
AI agent context
Run norq brand sync-agents to regenerate AGENTS_BRAND.md – a single Markdown file every AI coding agent reads as visual-identity context:
- Voice + principles (from
voice:) - Token reference list (every defined token by path)
- Heading → typography mapping
- Quickview (a few representative resolved values)
Use --scope rules for the smallest payload (principles only – system-prompt-friendly), --scope all for the full file (default), --check in CI to fail when the file is stale, --stdout to pipe straight into another process. norq init adds a ## Visual identity pointer to existing AGENTS.md / CLAUDE.md files at the project root.
DTCG conformance
Norq’s brand model maps to the DTCG 2025.10 spec for color, dimension, and typography token types. Known deviations:
| DTCG concept | Norq behaviour |
|---|---|
Color $value as object with colorSpace + components |
Norq stores hex; synthesises object on DTCG export. v1.x opens up wide-gamut authoring. |
Dimension units px, rem only |
Same, plus bare-number sugar → px. em / % rejected. |
Group-level $extends |
Norq has token-level basedOn: on typography only. |
| Composite types (shadow, gradient, border, transition) | Reserved schema slots; preserved via $extensions; not parsed/emitted. v1.1+. |
fontFamily and fontWeight as first-class DTCG types |
Collapsed into typography.*.fontFamily / .fontWeight. DTCG imports flattened. |
Pipes (darken, alpha, …) |
Norq extension, not DTCG. Pre-resolved before DTCG export. |
$extensions pass-through |
Supported on every token shape. |
Full conformance chapter: docs/spec/current/brand.md.
See also
norq brandCLI – init / show / import / export / diff / sync-agents- Configuration – the
brand:key innorq.config.yaml - Whitelabeling – per-recipient brand at send time for multi-tenant SaaS
- Lint Rules – full reference for brand and email rules