Norq

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:

  1. The brand: key in norq.config.yaml, if set, MUST be honoured.
  2. If the key is unset, parsers MUST look for ./brand.yaml next to the config file.
  3. 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.
  4. 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:

  1. Bare number (16) — sugar; MUST be expanded to {value: 16, unit: px} at parse time.
  2. Unit string ("16px", "1rem") — MUST be parsed into {value, unit}. The unit MUST be px or rem. Other units (em, %) MUST be rejected at parse with a brand/invalid-dimension diagnostic.
  3. 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:

  1. Strip the surrounding braces.
  2. Split the inner expression on | to separate the alias path from any pipe applications.
  3. Look up the alias path. Recognised section prefixes: colors, spacing, radii, typography, fonts.
  4. Recursively resolve the target token’s own raw field.
  5. Apply each pipe in left-to-right order to the resolved literal.

If any step fails:

  • Unknown pathbrand/broken-ref (Error).
  • Cyclebrand/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 to fonts.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:

  1. Start from a default TypographyToken.
  2. For each ancestor, walking root-to-leaf, merge non-None fields onto the accumulator.
  3. The final flattened token’s basedOn field MUST be None.

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 using modes.dark.colors overrides.
  • auto — emit @media (prefers-color-scheme: dark) CSS using modes.dark.colors overrides.

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 (h1h6 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.

  1. Built-in compiler defaults — hard-coded fallbacks (e.g. default font family, baseline padding, base text color).
  2. Brand theme tokensbrand.yaml tokens.colors / tokens.typography / tokens.spacing / tokens.radii. Includes element-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.
  3. Compiler inference — derived attributes the compiler infers from surrounding context. Examples: images in padding="none" sections default to padding="0"; text inside a dark-luminance background section defaults to white.
  4. brand.yaml defaults.<element-type> — per-element-type defaults from the brand (or, equivalently, from defaults: in template frontmatter for that template only).
  5. brand.yaml styles.<bundle> — named bundles applied via {.name} on the element (or, equivalently, from styles: in template frontmatter).
  6. 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).
  7. 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:

  1. MUST start with an <!-- AUTO-GENERATED ... --> HTML comment naming brand.yaml as the source.
  2. MUST include the following sections (in order), subject to the active scope (rules, brand, tokens, all):
    • # Visual identity ... header (always).
    • ## Brand voice (scope ≥ brand, when voice.description is set).
    • ## Principles (must follow) (scope ≥ rules, when voice.principles is non-empty).
    • ## Named styles ... (scope = all).
    • ## Token reference ... (scope ≥ tokens).
    • ## Heading → typography mapping (scope ≥ tokens).
    • ## Quickview (scope ≥ tokens).
    • ## Authoring rule (scope = all).
  3. MUST end with a single trailing newline.
  4. MUST be byte-for-byte reproducible from the same brand.yaml so --check does 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.norq so 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.yaml shape, 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.