Brand Spec
Defines the on-disk shape of brand.yaml, alias resolution semantics, color
pipe behaviour, typography basedOn deep-merge, mode handling, and the
per-template frontmatter override contract. The normative source of truth for
parser and resolver behaviour is the implementation under
crates/core/src/brand/; this document describes that behaviour in prose so
non-Rust consumers can implement compatible tooling.
The key words MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL in this document are to be interpreted as described in RFC 2119 and RFC 8174 when, and only when, they appear in all capitals, as shown here.
1. Overview
A Norq project’s visual identity lives in a single brand.yaml file. The file
is YAML-encoded and conformant with a documented subset of the W3C Design
Tokens Community Group spec (DTCG 2025.10). Norq parses brand.yaml once at
project load, resolves alias expressions and color pipes, and produces a
ResolvedBrand value that is the canonical source for downstream compilers.
Locator rules:
- The
brand:key innorq.config.yaml, if set, MUST be honoured. - If the key is unset, parsers MUST look for
./brand.yamlnext to the config file. - If neither is reachable, parsers MUST fall back to the compiled-in default
brand (mirrors the previous
EmailTheme::default()palette) and emit an info-level diagnostic. - If the key is set but the path does not resolve, parsers MUST treat the condition as an error.
2. File structure
A brand.yaml document MUST be a YAML mapping. Recognised top-level keys:
| Key | Type | Purpose |
|---|---|---|
$schema |
string | Optional schema URL for editor tooling. |
meta |
object | Authoring metadata. |
tokens |
object | Token sections (colors, typography, spacing, radii, codeTheme). |
styles |
object | Named composition bundles. |
defaults |
object | Per-element-type attribute defaults. |
element-typography |
object | Heading-tag → typography-token mapping. |
fonts |
object | Font specifications. |
modes |
object | Mode-aware overrides (currently only dark). |
voice |
object | Free-form voice description and bulleted principles. |
$extensions |
object | Lossless pass-through for unknown fields (DTCG forward-compat). |
Unknown top-level keys MUST be preserved on round-trip via $extensions.
Implementations MAY warn but MUST NOT fail.
3. Token sections
3.1 Colors
Color tokens are stored as either a short string form or a full object form.
Short form: the value is a string that MUST match one of:
- An sRGB hex literal:
#RRGGBB,#RRGGBBAA,#RGB, or#RGBA. - A reference:
{section.name}. - A pipeline expression:
{section.name | pipe args}(one or more pipes).
Full form: an object with a required raw field plus optional DTCG
sidecars ($description, $deprecated, $extensions). The raw field MUST
follow the same shape as the short form.
Color space: v1 implementations MUST accept only sRGB-space hex literals
in source. Wide-gamut spaces (display-p3, oklch, lab) MUST be preserved
losslessly via $extensions on import but MUST NOT be authored natively.
Output: the resolver MUST populate every color token’s value field with
the literal resolved string. For pure sRGB tokens this is a hex string; for
alpha-piped tokens it is an rgba(r, g, b, a) string suitable for inline CSS.
3.2 Dimensions (spacing, radii, fontSize, letterSpacing)
Dimension tokens accept three authoring shapes:
- Bare number (
16) — sugar; MUST be expanded to{value: 16, unit: px}at parse time. - Unit string (
"16px","1rem") — MUST be parsed into{value, unit}. The unit MUST bepxorrem. Other units (em,%) MUST be rejected at parse with abrand/invalid-dimensiondiagnostic. - DTCG object form (
{value: 16, unit: "px"}) — MUST be accepted on import for interop.
A dimension value MAY also be a {ref} string — resolution is deferred.
3.3 Typography
A typography token is a composite. Recognised fields:
| Field | Type | Notes |
|---|---|---|
basedOn |
string (typography-token name) | Norq-specific token-level inheritance. |
fontFamily |
string | CSS font-family OR {fonts.X} / {fonts.X.family} ref. |
fontSize |
dimension | See §3.2. |
fontWeight |
integer (100–1000) | CSS font-weight. |
lineHeight |
number | Multiplier (e.g. 1.6). DTCG dimension form MUST be coerced to a multiplier with a warning. |
letterSpacing |
dimension | See §3.2. |
fontFeature |
string | CSS font-feature-settings. |
fontVariation |
string | CSS font-variation-settings. |
basedOn is not the DTCG $extends keyword. DTCG $extends
operates on groups; Norq’s basedOn is per-token typography inheritance.
The keyword difference is intentional and MUST NOT be conflated.
basedOn resolution semantics: see §5.
3.4 codeTheme
tokens.codeTheme MUST be a string. The value names a syntect TextMate theme
used by the email compiler for fenced-code highlighting.
4. Alias resolution
A string token value of the form {path} is an alias. The resolver MUST:
- Strip the surrounding braces.
- Split the inner expression on
|to separate the alias path from any pipe applications. - Look up the alias path. Recognised section prefixes:
colors,spacing,radii,typography,fonts. - Recursively resolve the target token’s own
rawfield. - Apply each pipe in left-to-right order to the resolved literal.
If any step fails:
- Unknown path →
brand/broken-ref(Error). - Cycle →
brand/cyclic-ref(Error). Implementations MUST detect cycles via a per-resolution visited set.
Cross-section alias examples that MUST resolve correctly:
typography.body.fontFamily: "{fonts.sans}"resolves tofonts.sans.family.tokens.spacing.gutter: "{spacing.md}"resolves through the spacing section.tokens.colors.brand-hover: "{colors.brand | darken 10%}"resolves the alias first, then applies the pipe.
5. basedOn typography inheritance
tokens.typography.<name>.basedOn MUST name another typography token.
Implementations MUST flatten the inheritance chain by deep-merge:
- Start from a default
TypographyToken. - For each ancestor, walking root-to-leaf, merge non-
Nonefields onto the accumulator. - The final flattened token’s
basedOnfield MUST beNone.
Cycles MUST be detected and reported as brand/cyclic-ref. Unknown parents
MUST be reported as brand/broken-ref. The flattened token continues to
populate via the parent (or a fresh default if the parent is unknown).
6. Color pipes
Five pipes are supported in v1. Implementations MUST accept these and MUST
reject all others with a brand/unknown-pipe (Error) diagnostic.
| Pipe | Args | Operation |
|---|---|---|
darken <pct> |
percentage 0–100 | HSL-space lightness reduction. |
lighten <pct> |
percentage 0–100 | HSL-space lightness increase. |
alpha <0..1> |
float in [0, 1] | Sets alpha; result is rgba(r, g, b, a). |
mix <hex> <pct> |
hex literal + percentage | Linear interpolation toward <hex> by <pct>. |
contrast <target> |
hex literal | Returns the resolved color or its inverse, whichever has higher contrast against target. |
Pipe arguments out of range or of the wrong type MUST be reported as
brand/invalid-pipe-arg (Error). When a pipe argument is itself a {ref},
implementations MAY resolve it transitively but are not required to in v1.
DTCG export: pipes MUST be pre-resolved to literal values before DTCG
emission. DTCG has no concept of computed tokens, so the exported file is a
snapshot. Subsequent brand import SHALL read the literal hex rather than
the original pipe expression.
7. Modes
modes.colorMode MUST be one of light (default), dark, or auto.
light— render with the base palette only.dark— force a static dark render usingmodes.dark.colorsoverrides.auto— emit@media (prefers-color-scheme: dark)CSS usingmodes.dark.colorsoverrides.
Only the email channel honours dark-mode overrides in v1. Other channels
MUST ignore modes.dark and emit no related output.
modes.dark.colors MUST follow the same shape as tokens.colors. Aliases
within modes.dark.colors resolve against the merged brand
(tokens.colors + modes.dark.colors), so a dark-only token MAY reference a
dark-only override.
8. Named styles, defaults, element-typography
8.1 Styles
styles.<bundle> MUST be a flat map of attribute names to attribute values.
Values MAY be literal CSS, token names (e.g. bg: "brand"), or {ref}
expressions. Resolution happens at consumer request.
Templates apply a bundle via the {.bundle-name} syntax. Multiple styles
compose left-to-right; the rightmost style wins on per-attr conflicts.
Inline attrs MUST override styles.
8.2 Defaults
defaults.<element-type> MUST be a flat map of attribute names to values.
Recognised element types: image, button, heading, text, divider,
section. Other keys MUST emit brand/unknown-element-type (Warning).
Defaults sit at the lowest level of the cascade — see §9.
8.3 Element typography
element-typography MUST be a flat map from a Markdown heading tag (h1–
h6 or p) to a typography-token name. Implementations MUST resolve the
mapping and emit the resolved typography on the corresponding rendered
element.
A mapping that targets an undefined typography token MUST emit
brand/missing-typography (Warning). The element-typography mapping is
not overridable per template (see §10).
9. Cascade order
When resolving the final attribute set for an element, implementations MUST apply rules in this order, with later rows winning. This is the canonical cascade — cascade.md ships conformance examples that exercise every interaction documented here.
- Built-in compiler defaults — hard-coded fallbacks (e.g. default font family, baseline padding, base text color).
- Brand theme tokens —
brand.yamltokens.colors/tokens.typography/tokens.spacing/tokens.radii. Includeselement-typography.<tag>, which maps a tag (e.g.h1) to a typography token (e.g.heading-1); this resolves before any element-specific overrides apply. - Compiler inference — derived attributes the compiler infers from
surrounding context. Examples: images in
padding="none"sections default topadding="0"; text inside a dark-luminance background section defaults to white. brand.yamldefaults.<element-type>— per-element-type defaults from the brand (or, equivalently, fromdefaults:in template frontmatter for that template only).brand.yamlstyles.<bundle>— named bundles applied via{.name}on the element (or, equivalently, fromstyles:in template frontmatter).- Template frontmatter
brand:block — partial override of any of the above for this template only. References inside this block MUST resolve against the merged brand (the override is applied first, then aliases resolve). - Inline template attrs —
{key="value"}on the element itself.
Same rule applies inside any nested directive: each level inherits the merged attribute set from its parent, then layers its own overrides on top following the same order.
10. Per-template brand: frontmatter override
A template MAY include a brand: block in its frontmatter. The block is
shaped as a partial brand and merged onto the project brand at compile time.
Overridable sections:
| Section | Overridable | Why |
|---|---|---|
tokens.colors |
Yes | Campaign / promo variations. |
tokens.typography |
Yes | One-off editorial treatments. |
tokens.spacing |
Yes | Rare but valid. |
tokens.radii |
Yes | Rare but valid. |
tokens.codeTheme |
Yes | – |
styles.<bundle> |
Yes | Per-template CTA variants. Redefines the bundle for this template only. |
defaults.<element> |
Yes | Per-template image padding, etc. |
element-typography.<tag> |
No | Stable per-project by design. |
modes.dark |
No | Project-wide. |
fonts |
No | Delivery concern. |
voice, meta |
No | Not meaningful per-template. |
Unknown frontmatter keys MUST be preserved (forward-compat). Reserved keys
in the same namespace: brand, subject, preheader, title, dir,
lang, mode.
11. Lints
Brand lints run once per project against brand.yaml (or the compiled-in
defaults if no brand.yaml is reachable). The full registry, severity, and
message contract are defined in
docs/spec/current/linting.md. Brand-specific rules:
| Rule | Severity | Trigger |
|---|---|---|
brand/broken-ref |
Error | A {path} alias or basedOn target does not 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 argument has the wrong shape or is out of range. |
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 |
Warning | No colors.brand token defined. |
brand/missing-typography |
Warning | element-typography.<tag> references undefined typography. |
brand/font-not-delivered |
Warning | Typography fontFamily references {fonts.X} but fonts.X is missing. |
brand/orphaned-token |
Warning | Token defined but unreferenced. The 17 EmailTheme-mapped color names and the body / code typography tokens MUST be exempt. |
brand/unknown-element-type |
Warning | defaults.<x> for an element the email compiler does not recognise. |
The email/low-contrast rule MUST also walk brand.styles.* bundles that
declare both bg and color. Refs MUST resolve through the brand before
the WCAG-AA contrast check.
12. Generated AGENTS_BRAND.md
norq brand sync-agents MUST produce a deterministic Markdown file from the
resolved brand. The output:
- MUST start with an
<!-- AUTO-GENERATED ... -->HTML comment namingbrand.yamlas the source. - MUST include the following sections (in order), subject to the active
scope (
rules,brand,tokens,all):# Visual identity ...header (always).## Brand voice(scope ≥brand, whenvoice.descriptionis set).## Principles (must follow)(scope ≥rules, whenvoice.principlesis non-empty).## Named styles ...(scope =all).## Token reference ...(scope ≥tokens).## Heading → typography mapping(scope ≥tokens).## Quickview(scope ≥tokens).## Authoring rule(scope =all).
- MUST end with a single trailing newline.
- MUST be byte-for-byte reproducible from the same
brand.yamlso--checkdoes an equality test.
Implementations MAY add new section types in future versions provided the above ordering invariant is preserved.
13. Import / export
13.1 Importers
Implementations SHOULD provide importers for at least the following formats:
| Format | Detection |
|---|---|
| DESIGN.md | .md extension. |
| DTCG JSON | $schema field or any descendant $value field. |
| Figma Tokens Studio | Nested groups with {value, type} leaves. |
| Style Dictionary | Top-level groups color / size / font / radius. |
| Norq brand.yaml | meta: / tokens: / voice: keys. |
Detection MUST be content-driven first, extension-only as a tiebreaker. Unrecognised inputs MUST fail with an actionable error rather than silently fall through to a default.
13.2 Exporters
Implementations SHOULD provide exporters for at least:
- DTCG 2025.10 JSON. Norq-specific bits (styles, defaults,
element-typography, fonts, modes, voice, codeTheme) MUST be emitted under
$extensions.norqso a round-trip through DTCG preserves them. - Tailwind config stub.
module.exports = { theme: { extend: {...} } }. - CSS variables.
:root { --colors-brand: ...; }. Dark-mode overrides MUST emit a@media (prefers-color-scheme: dark)block. - Norq JSON. The native
brand.yamlshape, JSON-encoded.
All exporters MUST consume a resolved brand — pipes pre-resolved, aliases flattened. The exported file is a snapshot.
14. Known deviations from DTCG 2025.10
| 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. |
Group hierarchy (color.brand.primary). |
Norq flattens to colors.brand-primary. Export re-emits grouped. |
$type on groups. |
Dropped on import; type inferred from section. |
Typography lineHeight as number OR dimension. |
Multiplier (number) only; dimension form coerced with warning. |
| Composite types (shadow, gradient, border, transition, strokeStyle). | Reserved schema slots; preserved via $extensions; not parsed/emitted. v1.1+. |
fontFamily and fontWeight as first-class DTCG types. |
Collapsed into typography.*.fontFamily / .fontWeight. |
Pipes (darken, alpha, mix, contrast, lighten). |
Norq extension; pre-resolved before DTCG export. |
$extensions pass-through. |
Supported on every token shape. |
15. JSON Schema
A JSON Schema for brand.yaml is published at
schemas/norq-brand.schema.json.
Implementations SHOULD use it for editor validation.