Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/brand-functional-token-maps-export.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@primer/brand-primitives': minor
'@primer/brand-css': minor
'@primer/react-brand': minor
---

Added new JSON-based typography and size map exports at `/lib/design-tokens/json/typography.json` and `/lib/design-tokens/json/size.json`, listing the typography and size tokens from `@primer/brand-primitives`.
75 changes: 75 additions & 0 deletions packages/design-tokens/scripts/build-tokens.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const mediaQueryFormat = require('../src/formats/responsive-media-query')
const colorModeFormat = require('../src/formats/color-mode-attributes')
const oneDimensionalFormat = require('../src/formats/json-one-dimensional')
const isColorValue = require('../src/filters/isColorValue')
const isSizeValue = require('../src/filters/isSizeValue')

const lightJson = require('../src/tokens/base/colors/light')
const darkJson = require('../src/tokens/base/colors/dark')
Expand Down Expand Up @@ -339,6 +340,80 @@ const darkJson = require('../src/tokens/base/colors/dark')
},
})

/**
* Emit a one-dimensional JSON typography map of brand typography custom properties for
* downstream tooling. This is a name-based allowlist, so it must cover every `--brand-*` /
* `--base-*` typography custom property brand ships — including the core text-style tokens
* (`--brand-text-body-*`, `--brand-text-title-*`, `--brand-text-caption/subtitle/display/code*`
* and `--brand-fontStack-sansSerifDisplay`) that live only in `functional/typography/
* typography.json`. All values here are legitimate typography tokens (font family, weight, size,
* line height, letter spacing, font-feature settings), so no value-shape filter is needed. The
* sources are ordered so `typography-responsive.json` loads last and wins the handful of
* `--brand-fontStack-*` name collisions with the brand (Mona Sans) values. Base tokens are
* emitted without the `brand` prefix to match their CSS output (`--base-text-*`); functional
* tokens keep the `brand` prefix.
*/
buildPrimitives({
source: [
`tokens/functional/typography/typography.json`,
`tokens/base/typography/typography.json`,
`tokens/functional/typography/typography-responsive.json`,
],
namespace,
platforms: {
jsonTypography: {
prefix: namespace,
addPrefix: token => token.isSource && !token.filePath.replace(/\\/g, '/').includes('/base/typography/'),
buildPath: `${outputPath}/json/`,
transformGroup: 'css',
files: [
{
destination: `typography.json`,
format: `json/one-dimensional`,
filter: token => token.isSource,
},
],
},
},
})

/**
* Emit a one-dimensional JSON size map of brand size custom properties (base size scale +
* functional size, border and breakpoint tokens) for downstream tooling. The source files are
* the same ones brand ships in its CSS (`base/size/size.css`, `functional/size/size.css`,
* `functional/size/breakpoints.css`, `functional/size/border.css`); the pointer-specific
* `size-fine` / `size-coarse` variants and the `viewport` media-query ranges are excluded
* because brand does not import them. Size tokens are additionally selected by inspecting the
* emitted value (`isSizeValue`), so non-size values that live alongside sizes (animation
* easing/duration, `inset ...` border composites) are dropped. Base tokens are emitted without
* the `brand` prefix to match their CSS output (`--base-size-*`); functional tokens keep the
* `brand` prefix.
*/
buildPrimitives({
source: [
`tokens/base/size/size.json`,
`tokens/functional/size/size.json`,
`tokens/functional/size/breakpoints.json`,
`tokens/functional/size/border.json`,
],
namespace,
platforms: {
jsonSize: {
prefix: namespace,
addPrefix: token => token.isSource && !token.filePath.replace(/\\/g, '/').includes('/base/size/'),
buildPath: `${outputPath}/json/`,
transformGroup: 'css',
files: [
{
destination: `size.json`,
format: `json/one-dimensional`,
filter: token => token.isSource && isSizeValue(token.value),
},
],
},
},
})

/**
* Step 4:
* Clean up the temporary directory
Expand Down
39 changes: 39 additions & 0 deletions packages/design-tokens/src/filters/isSizeValue.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* @description Predicate that decides whether a token's value is a CSS size/length, used to
* restrict a one-dimensional export to spacing/size tokens. Brand size sources mix value kinds:
* the size files hold a couple of animation tokens (`--brand-control-animation-easing`,
* `--brand-control-animation-duration`), the border file holds `inset ...` shadow composites,
* and the viewport file holds media-query ranges (`(max-width: ...)`). Rather than trusting file
* paths, this inspects the emitted value: a value is treated as a size when it is a bare length
* (`px` / `rem` / `em` / viewport / `ch` units), a math function (`max()` / `min()` / `clamp()`
* / `calc()`), or a `var(...)` reference to another size token. It excludes time values
* (`80ms`, `0.6s`), easing (`cubic-bezier(...)`), `inset ...` composites, media-query ranges,
* `none`, and `var(...)` references into the animation / motion domain.
*
* @param {string} value the token's resolved value
* @returns {boolean} `true` when the value is size-shaped
*/
const SIZE_FUNCTION_PREFIXES = ['max(', 'min(', 'clamp(', 'calc(']
const LENGTH_VALUE = /^-?(\d+\.?\d*|\.\d+)(px|rem|em|vh|vw|vmin|vmax|ch)$/
const NON_SIZE_VAR = /(animation|easing|duration|transition|motion|cubic)/

const isSizeValue = value => {
if (typeof value !== 'string') return false

const normalized = value.trim().toLowerCase()

if (normalized === '') return false

if (normalized.startsWith('(')) return false
if (normalized.startsWith('inset')) return false
if (normalized.includes('cubic-bezier')) return false
if (/^-?(\d+\.?\d*|\.\d+)(ms|s)$/.test(normalized)) return false

if (normalized.startsWith('var(')) return !NON_SIZE_VAR.test(normalized)

if (SIZE_FUNCTION_PREFIXES.some(prefix => normalized.startsWith(prefix))) return true

return LENGTH_VALUE.test(normalized)
}

module.exports = isSizeValue
65 changes: 65 additions & 0 deletions packages/design-tokens/src/filters/isSizeValue.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
const {describe, it, expect} = require('@jest/globals')
const isSizeValue = require('./isSizeValue')

describe('isSizeValue', () => {
it('keeps bare length values', () => {
expect(isSizeValue('2px')).toBe(true)
expect(isSizeValue('0.125rem')).toBe(true)
expect(isSizeValue('624.9375rem')).toBe(true)
expect(isSizeValue('-0.035em')).toBe(true)
expect(isSizeValue('20rem')).toBe(true)
})

it('keeps viewport length units', () => {
expect(isSizeValue('100vh')).toBe(true)
expect(isSizeValue('50vw')).toBe(true)
expect(isSizeValue('2ch')).toBe(true)
})

it('keeps math functions', () => {
expect(isSizeValue('max(1px, 0.0625rem)')).toBe(true)
expect(isSizeValue('min(2px, 0.125rem)')).toBe(true)
expect(isSizeValue('clamp(1rem, 2vw, 3rem)')).toBe(true)
expect(isSizeValue('calc(48rem - 0.02px)')).toBe(true)
})

it('keeps var() references to size tokens', () => {
expect(isSizeValue('var(--base-size-16)')).toBe(true)
expect(isSizeValue('var(--brand-borderWidth-thin)')).toBe(true)
})

it('excludes var() references into the animation domain', () => {
expect(isSizeValue('var(--brand-animation-easing-glide)')).toBe(false)
expect(isSizeValue('var(--brand-animation-duration-default)')).toBe(false)
})

it('excludes time values', () => {
expect(isSizeValue('80ms')).toBe(false)
expect(isSizeValue('0.6s')).toBe(false)
})

it('excludes easing functions', () => {
expect(isSizeValue('cubic-bezier(0.16, 1, 0.3, 1)')).toBe(false)
})

it('excludes inset composites', () => {
expect(isSizeValue('inset 0 0 0 var(--brand-borderWidth-thin)')).toBe(false)
})

it('excludes media-query ranges', () => {
expect(isSizeValue('(max-width: calc(48rem - 0.02px))')).toBe(false)
expect(isSizeValue('(orientation: portrait)')).toBe(false)
})

it('excludes unitless and keyword values', () => {
expect(isSizeValue('0')).toBe(false)
expect(isSizeValue('1.5')).toBe(false)
expect(isSizeValue('none')).toBe(false)
})

it('excludes non-string values', () => {
expect(isSizeValue(undefined)).toBe(false)
expect(isSizeValue(null)).toBe(false)
expect(isSizeValue(4)).toBe(false)
})
})
Loading