diff --git a/packages/@react-spectrum/s2/stories/prose.mdx b/packages/@react-spectrum/s2/stories/prose.mdx new file mode 100644 index 00000000000..d521094ea48 --- /dev/null +++ b/packages/@react-spectrum/s2/stories/prose.mdx @@ -0,0 +1,184 @@ +{/* Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. */} + +# An Example Article to Test Prose Styles + +*Published March 12, 2026 · 8 min read* + +When teams outgrow a handful of shared CSS variables, they usually need something stronger: a **design token pipeline** that turns brand decisions into typed, versioned values every component can consume. This article walks through how one product team migrated from scattered hex codes to a single source of truth—and what they learned along the way. + +A token pipeline is not just a JSON file in a repo. It is the contract between design and engineering: names, scales, semantic aliases, and the build steps that keep documentation, Figma libraries, and runtime themes in sync. + +--- + +## Why tokens matter + +Hard-coded values drift. A button might use `#0265DC` in one package and `#1473E6` in another. Tokens replace that ambiguity with **stable names** like `color-accent-default` that map to platform-specific outputs. + +Good token systems share a few traits: + +- **Semantic naming** — components reference intent, not raw values +- **Layered aliases** — global palette → semantic roles → component tokens +- **Automated distribution** — one change propagates to CSS, Swift, and Android + +> "We stopped debating hex codes in pull requests once tokens became the only thing components were allowed to import." +> — *Maya Chen, Design Systems Lead* + +### From palette to semantics + +Start with a **global palette**: neutrals, brand hues, and status colors at fixed steps (`gray-100` through `gray-900`). Semantic tokens then *alias* those steps to roles such as `text-primary` or `border-focus`. + +#### Example alias chain + +A focus ring might resolve like this: + +1. `focus-ring-color` → `color-blue-800` +2. `color-blue-800` → `#0265DC` +3. Runtime theme overrides remap aliases without touching component code + +##### Naming conventions + +Keep names **lowercase**, **kebab-case**, and **role-oriented**. Avoid embedding the literal value in the name—`blue-600` is fine for palette steps; `primary-button-background` is better for semantics. + +###### Edge cases + +Even `h6`-level detail deserves a home: document when a token is *deprecated* versus *removed*, and link to the migration guide in the same PR that flips the alias. + +--- + +## Planning the migration + +Before touching production components, the team audited every color, spacing, and typography value in the codebase. They grouped findings into three buckets: + +1. **Direct replacements** — values that already matched the new palette +2. **Near matches** — values within one step on the scale +3. **One-offs** — legacy colors that needed designer sign-off + +Nested decisions often appear in the same list: + +1. Choose a token format (DTCG JSON was the winner) + - Validate against the [Design Tokens Community Group spec](https://design-tokens.github.io/community-group/format/) + - Add JSON Schema in CI so bad exports fail fast +2. Pick build tooling + - [Style Dictionary](https://amzn.github.io/style-dictionary/) for multi-platform output + - Custom transforms for Spectrum-style macro keys +3. Roll out by package + - Start with primitives (`Button`, `Link`) + - Expand to form controls, then layout + +### Pre-migration checklist + +- [x] Inventory existing CSS variables and hard-coded literals +- [x] Align Figma variables with token names +- [ ] Enable lint rules blocking raw color literals in new code +- [ ] Publish a changelog entry for breaking alias renames + +--- + +## Implementation notes + +The build script reads `tokens.json`, resolves aliases, and emits platform files. A minimal Node entry point looks like this: + +```js +import StyleDictionary from 'style-dictionary'; + +StyleDictionary.extend({ + source: ['tokens/**/*.json'], + platforms: { + css: { + transformGroup: 'css', + buildPath: 'dist/css/', + files: [{destination: 'variables.css', format: 'css/variables'}] + }, + js: { + transformGroup: 'js', + buildPath: 'dist/js/', + files: [{destination: 'tokens.js', format: 'javascript/es6'}] + } + } +}).buildAllPlatforms(); +``` + +Run it after every token change: + +```bash +yarn build:tokens && yarn test:tokens +``` + +Run this from the project root after every token change, or trigger your configured build task with ⌘ Enter. + +Components then import semantic values instead of literals: + +```tsx +import {style} from '../style/spectrum-theme' with {type: 'macro'}; + +export function PrimaryButton() { + return ( + + ); +} +``` + +Inline code appears everywhere in real docs: set `backgroundColor: 'accent-default'` in macros, grep for `#0265DC`, or wrap paths like `tokens/color/semantic.json` in backticks inside list items and headings alike. + +--- + +## Token reference + +The table below shows a trimmed set of semantic color tokens and their light-theme values. Dark theme swaps the alias targets, not the names components use. + +
TokenRoleLight valueUsed by
color-background-basePage canvasgray-50Body, layouts
color-text-primaryDefault copygray-900Text, Heading
color-accent-defaultPrimary actionsblue-800Button, Link
color-border-focusFocus ringblue-800All interactive controls
color-negative-defaultErrorsred-700FieldError, InlineAlert
+ +For spacing, the team standardized on a **4 px grid**: `size-100` (4px), `size-200` (8px), `size-300` (12px), and so on. Typography tokens pair `font-size` with `line-height` so prose blocks like this paragraph stay readable at every breakpoint. + +
+ Design token pipeline diagram +
Figure 1. Global palette values feed semantic aliases, which components consume through typed APIs.
+
+ +--- + +## Communication and rollout + +Documentation is part of the pipeline. The team shipped: + +- A **migration guide** linked from the monorepo README + + The guide covers alias renames, codemods, and before/after examples for each package. Teams were asked to land migrations behind a feature flag when touching more than one semantic token at a time. + + A short *What's changing* section at the top reduced support questions. Link to the changelog for each release so readers know which tokens moved in which version. +- Storybook stories that render swatches from live token output +- Office hours for product squads still on legacy variables + +When announcing breaking renames, be explicit. `color-brand-primary` was retired in v3; use `color-accent-default` instead. Mix **bold**, *italic*, and ***bold italic*** emphasis sparingly so warnings stand out without shouting. + +External references helped the team stay aligned: + +- [Design Tokens Community Group format](https://design-tokens.github.io/community-group/format/) +- [Adobe Spectrum design tokens documentation](https://spectrum.adobe.com/page/design-tokens/) +- Internal RFC: *"Semantic color roles for Express mobile"* + +--- + +## Closing thoughts + +A token pipeline pays off when **designers edit values**, **build tools propagate them**, and **components never hard-code literals**. The upfront audit is tedious, but the alternative—endless hex drift across packages—is worse. + +Start small: one palette file, one build target, one component migrated end to end. Measure success by the number of raw color literals left in `git grep`, not by how pretty the JSON looks on day one. + +--- + +*Questions about this guide? Open a discussion in `#design-systems` or file an issue with the `tokens` label.* diff --git a/packages/@react-spectrum/s2/stories/prose.stories.tsx b/packages/@react-spectrum/s2/stories/prose.stories.tsx new file mode 100644 index 00000000000..85fd5e2b060 --- /dev/null +++ b/packages/@react-spectrum/s2/stories/prose.stories.tsx @@ -0,0 +1,30 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type {Meta} from '@storybook/react'; +import {prose} from '../style/prose' with {type: 'macro'}; +// @ts-ignore +import ProseExample from './prose.mdx'; +import {style} from '../style/spectrum-theme' with {type: 'macro'}; + +const meta: Meta = { + tags: ['autodocs'], + title: 'Prose' +}; + +export default meta; + +export const Example = () => ( +
+ +
+); diff --git a/packages/@react-spectrum/s2/style/prose.ts b/packages/@react-spectrum/s2/style/prose.ts new file mode 100644 index 00000000000..327d6020028 --- /dev/null +++ b/packages/@react-spectrum/s2/style/prose.ts @@ -0,0 +1,255 @@ +import {colorToken, getToken} from './tokens'; +import { + colorTokenToString, + fontFamily, + fontSize, + fontSizeCalc, + fontWeight, + lineHeight, + resolveColorToken +} from './spectrum-theme'; +import type {MacroContext} from '@parcel/macros'; + +const marginTop = { + body: getToken('body-margin-multiplier') + 'em', + heading: getToken('heading-margin-top-multiplier') + 'em', + title: getToken('title-margin-top-multiplier') + 'em', + detail: getToken('detail-margin-top-multiplier') + 'em' +} as const; + +const marginBottom = { + body: getToken('body-margin-multiplier') + 'em', + heading: getToken('heading-margin-bottom-multiplier') + 'em', + title: getToken('title-margin-bottom-multiplier') + 'em', + detail: getToken('detail-margin-bottom-multiplier') + 'em' +} as const; + +export function prose(this: MacroContext | void) { + let rules = { + '.prose': font('body'), + h1: { + ...font('heading-xl'), + ...margin('heading') + }, + h2: { + ...font('heading-lg'), + ...margin('heading') + }, + h3: { + ...font('heading'), + ...margin('heading') + }, + h4: { + ...font('heading-sm'), + ...margin('heading') + }, + h5: { + ...font('heading-xs'), + ...margin('heading') + }, + h6: { + ...font('heading-2xs'), + ...margin('heading') + }, + pre: { + ...font('code-sm'), + ...margin('body'), + borderRadius: getToken('corner-radius-large-default'), + backgroundColor: colorTokenToString( + resolveColorToken(colorToken('background-layer-1-color')) + ), + margin: 0, + padding: '16px', + width: '100%', + overflow: 'auto', + boxSizing: 'border-box' + }, + p: { + ...margin('body') + }, + 'ul, ol': { + paddingInlineStart: `${24 / 16}rem`, + marginTop: { + default: marginTop.body, + ':is(li > *)': 0 + }, + marginBottom: { + default: marginBottom.body, + ':is(li > *)': 0 + } + }, + ul: { + listStyleType: 'disc' + }, + ol: { + listStyleType: 'decimal' + }, + 'li > p:last-child:not(:first-child)': { + marginBottom: marginBottom.body + }, + blockquote: { + ...margin('body'), + borderStyle: 'solid', + borderWidth: 0, + borderColor: colorTokenToString(resolveColorToken(colorToken('gray-200'))), + borderInlineStartWidth: 2, + paddingInlineStart: 12, + marginInlineStart: 4 + }, + hr: { + marginBlock: '32px', + height: '2px', + borderRadius: '2px', + borderStyle: 'none', + backgroundColor: colorTokenToString(resolveColorToken(colorToken('gray-200'))) + }, + 'code:not(pre code)': { + ...font('code'), + fontSize: 'inherit', + backgroundColor: colorTokenToString( + resolveColorToken(colorToken('background-layer-1-color')) + ), + paddingInline: '4px', + borderWidth: 1, + borderColor: colorTokenToString(resolveColorToken(colorToken('gray-100'))), + borderStyle: 'solid', + borderRadius: getToken('corner-radius-small-default'), + whiteSpace: 'pre-wrap' + }, + kbd: { + ...font('ui'), + fontSize: 'inherit', + paddingInline: '8px', + paddingBlock: '2px', + whiteSpace: 'nowrap', + backgroundColor: colorTokenToString(resolveColorToken(colorToken('gray-100'))), + borderRadius: getToken('corner-radius-small-default'), + unicodeBidi: 'plaintext' + }, + a: { + color: { + default: colorTokenToString(resolveColorToken(colorToken('accent-content-color-default'))), + ':hover': colorTokenToString(resolveColorToken(colorToken('accent-content-color-hover'))), + ':active': colorTokenToString(resolveColorToken(colorToken('accent-content-color-down'))) + }, + textDecoration: 'underline', + transition: 'color 200ms' + }, + ':is(h1, h2, h3, h4, h5, h6, hr) + *': { + marginTop: 0 + }, + table: { + ...font('ui'), + ...margin('body'), + backgroundColor: colorTokenToString(resolveColorToken(colorToken('gray-25'))), + borderRadius: getToken('corner-radius-medium-default'), + borderColor: colorTokenToString(resolveColorToken(colorToken('gray-300'))), + borderWidth: '1px', + borderStyle: 'solid', + overflow: 'hidden', + borderSpacing: 0, + width: 'full' + }, + thead: { + backgroundColor: colorTokenToString(resolveColorToken(colorToken('gray-75'))), + borderTopRadius: 'default' + }, + th: { + paddingInline: '16px', + textAlign: 'start', + fontWeight: 'bold', + borderColor: colorTokenToString(resolveColorToken(colorToken('gray-300'))), + borderWidth: 0, + borderBottomWidth: 1, + borderStyle: 'solid', + height: '32px', + boxSizing: 'border-box' + }, + td: { + paddingInline: '16px', + paddingBlock: '4px', + borderWidth: 0, + borderBottomWidth: { + default: '1px', + ':is(tbody:last-child > tr:last-child > *)': 0 + }, + borderStyle: 'solid', + borderColor: colorTokenToString(resolveColorToken(colorToken('gray-300'))), + boxSizing: 'border-box' + }, + 'img, video': { + maxWidth: '100%' + }, + figure: { + ...margin('body'), + marginInline: 0 + }, + figcaption: { + ...font('body-sm'), + textAlign: 'center' + } + }; + + let css = ''; + for (let key in rules) { + let selector = key === '.prose' ? '.prose' : `.prose ${key}`; + css += `${selector} {\n`; + let properties = rules[key]; + for (let property in properties) { + let value = properties[property]; + let prop = property.replace(/([a-z])([A-Z])/g, (_, a, b) => `${a}-${b.toLowerCase()}`); + if (typeof value === 'object') { + if (value.default) { + css += ` ${prop}: ${value.default};\n`; + } + for (let condition in value) { + // eslint-disable-next-line + if (condition === 'default') { + continue; + } + css += ` ${condition.startsWith(':') ? '&' : ''}${condition} { ${prop}: ${value[condition]}; }\n`; + } + } else { + css += ` ${prop}: ${value};\n`; + } + } + + css += `}\n\n`; + } + + this?.addAsset({ + type: 'css', + content: css + }); + + return 'prose'; +} + +function font(value: keyof typeof fontSize) { + let type = value.split('-')[0]; + let size = fontSize[value]; + return { + fontFamily: fontFamily[type === 'code' ? 'code' : 'sans'], + '--fs': `pow(1.125, ${size})`, + fontSize: `round(${fontSizeCalc} / 16 * 1rem, 1px)`, + fontWeight: + fontWeight[type === 'heading' || type === 'title' || type === 'detail' ? type : 'normal'], + lineHeight: lineHeight[type], + color: colorTokenToString( + resolveColorToken(colorToken(type === 'ui' ? 'body-color' : (`${type}-color` as any))) + ) + }; +} + +function margin(value: keyof typeof marginTop) { + return { + marginTop: { + default: marginTop[value], + ':first-child': 0 + }, + marginBottom: { + default: marginBottom[value], + ':last-child': 0 + } + }; +} diff --git a/packages/@react-spectrum/s2/style/spectrum-theme.ts b/packages/@react-spectrum/s2/style/spectrum-theme.ts index 9bd80ef108a..3690d893241 100644 --- a/packages/@react-spectrum/s2/style/spectrum-theme.ts +++ b/packages/@react-spectrum/s2/style/spectrum-theme.ts @@ -119,7 +119,7 @@ const baseColors = { }; // Resolves a color to its most basic form, following all aliases. -function resolveColorToken(token: string | ColorToken | ColorRef): ColorToken { +export function resolveColorToken(token: string | ColorToken | ColorRef): ColorToken { if (typeof token === 'string') { return { type: 'color', @@ -150,7 +150,7 @@ function resolveColorToken(token: string | ColorToken | ColorRef): ColorToken { }; } -function colorTokenToString(token: ColorToken, opacity?: string | number) { +export function colorTokenToString(token: ColorToken, opacity?: string | number) { let result = token.light === token.dark ? token.light : `light-dark(${token.light}, ${token.dark})`; if (opacity) { @@ -577,7 +577,9 @@ const timingFunction = { let durationValue = (value: number | string) => (typeof value === 'number' ? value + 'ms' : value); const fontWeightBase = { - normal: '400', + normal: { + default: '400' + }, medium: { default: '500' }, @@ -589,32 +591,31 @@ const fontWeightBase = { default: '800', ':lang(ja, ko, zh)': '700' // Adobe Clean Han uses 700 as the extra bold weight. }, - black: '900' + black: { + default: '900' + } } as const; -const fontWeight = { +export const fontWeight = { ...fontWeightBase, heading: { - default: - fontWeightBase[getToken('heading-sans-serif-font-weight') as keyof typeof fontWeightBase], + ...fontWeightBase[getToken('heading-sans-serif-font-weight') as keyof typeof fontWeightBase], ':lang(ja, ko, zh, zh-Hant, zh-Hans)': fontWeightBase[getToken('heading-cjk-font-weight') as keyof typeof fontWeightBase] }, title: { - default: - fontWeightBase[getToken('title-sans-serif-font-weight') as keyof typeof fontWeightBase], + ...fontWeightBase[getToken('title-sans-serif-font-weight') as keyof typeof fontWeightBase], ':lang(ja, ko, zh, zh-Hant, zh-Hans)': fontWeightBase[getToken('title-cjk-font-weight') as keyof typeof fontWeightBase] }, detail: { - default: - fontWeightBase[getToken('detail-sans-serif-font-weight') as keyof typeof fontWeightBase], + ...fontWeightBase[getToken('detail-sans-serif-font-weight') as keyof typeof fontWeightBase], ':lang(ja, ko, zh, zh-Hant, zh-Hans)': fontWeightBase[getToken('detail-cjk-font-weight') as keyof typeof fontWeightBase] } } as const; -const i18nFonts = { +export const i18nFonts = { ':lang(ar)': 'adobe-clean-arabic, myriad-arabic, ui-sans-serif, system-ui, sans-serif', ':lang(he)': 'adobe-clean-hebrew, myriad-hebrew, ui-sans-serif, system-ui, sans-serif', ':lang(ja)': @@ -632,7 +633,7 @@ const i18nFonts = { "adobe-clean-han-simplified-c, source-han-simplified-c, 'SimSun', 'Heiti SC Light', sans-serif" } as const; -const fontSize = { +export const fontSize = { // The default font size scale is for use within UI components. 'ui-xs': fontSizeToken('font-size-50'), 'ui-sm': fontSizeToken('font-size-75'), @@ -681,14 +682,58 @@ const fontSize = { 'code-xl': fontSizeToken('code-size-xl') } as const; +export const fontFamily = { + sans: { + default: + 'var(--s2-font-family-sans, adobe-clean-spectrum-vf), adobe-clean-variable, adobe-clean, ui-sans-serif, system-ui, sans-serif', + ...i18nFonts + }, + serif: { + default: + 'var(--s2-font-family-serif, adobe-clean-spectrum-srf-vf), adobe-clean-serif, "Source Serif", Georgia, serif', + ...i18nFonts + }, + code: 'source-code-pro, "Source Code Pro", Monaco, monospace' +} as const; + // Line heights linearly interpolate between 1.3 and 1.15 for font sizes between 10 and 32, rounded to the nearest 2px. // Text above 32px always has a line height of 1.15. -const fontSizeCalc = 'var(--s2-font-size-base, 14) * var(--fs)'; +export const fontSizeCalc = 'var(--s2-font-size-base, 14) * var(--fs)'; const minFontScale = 1.15; const maxFontScale = 1.3; const minFontSize = 10; const maxFontSize = 32; const lineHeightCalc = `round(1em * (${minFontScale} + (1 - ((min(${maxFontSize}, ${fontSizeCalc}) - ${minFontSize})) / ${maxFontSize - minFontSize}) * ${(maxFontScale - minFontScale).toFixed(2)}), 2px)`; +export const lineHeight = { + // See https://spectrum.corp.adobe.com/page/typography/#Line-height + ui: { + // Calculate line-height based on font size. + default: lineHeightCalc, + // CJK fonts use a larger line-height. + ':lang(ja, ko, zh, zh-Hant, zh-Hans, zh-CN, zh-SG)': getToken('line-height-200') + }, + heading: { + default: lineHeightCalc, + ':lang(ja, ko, zh, zh-Hant, zh-Hans, zh-CN, zh-SG)': getToken('heading-cjk-line-height') + }, + title: { + default: lineHeightCalc, + ':lang(ja, ko, zh, zh-Hant, zh-Hans, zh-CN, zh-SG)': getToken('title-cjk-line-height') + }, + body: { + // Body text uses spacious line height, 1.5 for all font sizes. + default: getToken('body-line-height'), + ':lang(ja, ko, zh, zh-Hant, zh-Hans, zh-CN, zh-SG)': getToken('body-cjk-line-height') + }, + detail: { + default: lineHeightCalc, + ':lang(ja, ko, zh, zh-Hant, zh-Hans, zh-CN, zh-SG)': getToken('detail-cjk-line-height') + }, + code: { + default: getToken('code-line-height'), + ':lang(ja, ko, zh, zh-Hant, zh-Hans, zh-CN, zh-SG)': getToken('code-cjk-line-height') + } +} as const; export const style = createTheme({ properties: { @@ -928,19 +973,7 @@ export const style = createTheme({ ), // text - fontFamily: { - sans: { - default: - 'var(--s2-font-family-sans, adobe-clean-spectrum-vf), adobe-clean-variable, adobe-clean, ui-sans-serif, system-ui, sans-serif', - ...i18nFonts - }, - serif: { - default: - 'var(--s2-font-family-serif, adobe-clean-spectrum-srf-vf), adobe-clean-serif, "Source Serif", Georgia, serif', - ...i18nFonts - }, - code: 'source-code-pro, "Source Code Pro", Monaco, monospace' - }, + fontFamily, fontSize: new ExpandedProperty( ['--fs', 'fontSize'], value => { @@ -965,36 +998,7 @@ export const style = createTheme({ }, fontWeight ), - lineHeight: { - // See https://spectrum.corp.adobe.com/page/typography/#Line-height - ui: { - // Calculate line-height based on font size. - default: lineHeightCalc, - // CJK fonts use a larger line-height. - ':lang(ja, ko, zh, zh-Hant, zh-Hans, zh-CN, zh-SG)': getToken('line-height-200') - }, - heading: { - default: lineHeightCalc, - ':lang(ja, ko, zh, zh-Hant, zh-Hans, zh-CN, zh-SG)': getToken('heading-cjk-line-height') - }, - title: { - default: lineHeightCalc, - ':lang(ja, ko, zh, zh-Hant, zh-Hans, zh-CN, zh-SG)': getToken('title-cjk-line-height') - }, - body: { - // Body text uses spacious line height, 1.5 for all font sizes. - default: getToken('body-line-height'), - ':lang(ja, ko, zh, zh-Hant, zh-Hans, zh-CN, zh-SG)': getToken('body-cjk-line-height') - }, - detail: { - default: lineHeightCalc, - ':lang(ja, ko, zh, zh-Hant, zh-Hans, zh-CN, zh-SG)': getToken('detail-cjk-line-height') - }, - code: { - default: getToken('code-line-height'), - ':lang(ja, ko, zh, zh-Hant, zh-Hans, zh-CN, zh-SG)': getToken('code-cjk-line-height') - } - }, + lineHeight, listStyleType: ['none', 'disc', 'decimal'] as const, listStylePosition: ['inside', 'outside'] as const, textTransform: ['uppercase', 'lowercase', 'capitalize', 'none'] as const,