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..9c2dfe0e308 --- /dev/null +++ b/packages/@react-spectrum/s2/stories/prose.stories.tsx @@ -0,0 +1,480 @@ +/* + * 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 {createPortal} from 'react-dom'; +import type {Meta} from '@storybook/react'; +import {prose} from '../style/prose' with {type: 'macro'}; +// @ts-ignore +import ProseExample from './prose.mdx'; +import React, {ReactNode, useEffect, useRef, useState} from 'react'; +import {style} from '../style/spectrum-theme' with {type: 'macro'}; + +const meta: Meta = { + tags: ['autodocs'], + title: 'Prose' +}; + +export default meta; + +export const Example = () => ( + +
+ +
+
+); + +let componentMapping: Record = { + 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', + p: 'font:body; margin:body', + ul: 'font:body; margin:body', + ol: 'font:body; margin:body', + li: 'font:body; margin:body', + blockquote: 'font:body; margin:body', + hr: 'margin:32px', + code: 'font:code; margin:none', + kbd: 'font:ui; margin:none', + a: 'font: body; margin:none', + table: 'font:ui; margin:body', + thead: 'font:ui; margin:none', + th: 'font:ui; margin:none', + td: 'font:ui; margin:none', + img: 'font:body; margin:none', + video: 'font:body; margin:none', + figure: 'font:body; margin:body', + figcaption: 'font:body-sm; margin:body' +}; + +// --------------------------------------------------------------------------- +// Margin visualizer (story-only debugging tool) +// +// Toggling the button overlays every non-zero top/bottom (block) margin in the +// prose article. Each margin is drawn as a red box positioned over the margin +// gap, with a label to its right showing the responsible CSS selector and the +// margin size. When two margin boxes overlap (e.g. collapsed adjacent margins), +// the taller one wins as the red box and the shorter ones are drawn as blue +// vertical lines off to the side, each with their own compact label. +// --------------------------------------------------------------------------- + +interface MarginBox { + side: 'top' | 'bottom'; + size: number; + // Margin value in em (its authored unit), relative to the element's font-size. + em: number; + selector: string; + // The pair of tags the margin sits between, e.g. "p + p" or "h3 + p". + pair: string; + element: Element; + mapping: string; + // Document coordinates. + left: number; + top: number; + width: number; + height: number; + type: 'red' | 'blue'; + // Position within its overlap group (0 = tallest/red). + groupIndex: number; +} + +function MarginVisualizer({children}: {children: ReactNode}) { + let [active, setActive] = useState(false); + let [boxes, setBoxes] = useState([]); + let ref = useRef(null); + + useEffect(() => { + if (!active) { + setBoxes([]); + return; + } + + let recompute = () => { + if (ref.current) { + setBoxes(computeBoxes(ref.current)); + } + }; + + // Wait a frame so layout/fonts have settled before measuring. + let raf = requestAnimationFrame(recompute); + window.addEventListener('resize', recompute); + return () => { + cancelAnimationFrame(raf); + window.removeEventListener('resize', recompute); + }; + }, [active]); + + return ( + <> + +
{children}
+ {active && createPortal(, document.body)} + + ); +} + +function MarginOverlay({boxes}: {boxes: MarginBox[]}) { + return ( +
+ {boxes.map((box, i) => + box.type === 'red' ? : + )} +
+ ); +} + +function RedBox({box}: {box: MarginBox}) { + return ( + <> +
+
+
{box.pair}
+
+ {box.side === 'top' ? 'margin-top' : 'margin-bottom'}: {box.em}em +
+
+ + ); +} + +function BlueLine({box}: {box: MarginBox}) { + // Step each successive blue line further into the left gutter. + let offset = (box.groupIndex - 1) * 64; + let x = box.left - 12 - offset; + return ( + <> +
+
+
+ {box.pair} + {box.mapping && {box.mapping}} +
+
+ {box.side === 'top' ? 'margin-top' : 'margin-bottom'}: {box.em}em +
+
+ + ); +} + +function computeBoxes(root: HTMLElement): MarginBox[] { + let prose = root.querySelector('.prose') ?? root; + let scrollX = window.scrollX; + let scrollY = window.scrollY; + let entries: Omit[] = []; + + for (let el of prose.querySelectorAll('*')) { + let cs = getComputedStyle(el); + let rect = el.getBoundingClientRect(); + let fontSize = parseFloat(cs.fontSize); + let tag = el.tagName.toLowerCase(); + for (let side of ['top', 'bottom'] as const) { + let prop: 'margin-top' | 'margin-bottom' = side === 'top' ? 'margin-top' : 'margin-bottom'; + let size = parseFloat(cs.getPropertyValue(prop)); + if (!size || size <= 0) { + continue; + } + // The tags on either side of the margin gap. For a bottom margin the + // element sits above and the next flow element below; vice versa for top. + let neighbor = adjacentTag(el, side === 'top' ? 'previous' : 'next', prose); + let pair = side === 'top' ? `${neighbor} + ${tag}` : `${tag} + ${neighbor}`; + entries.push({ + side, + size, + // em resolves against the element's own font-size. + em: Math.round((size / fontSize) * 1000) / 1000, + selector: findSelector(el, prop), + pair, + element: el, + mapping: componentMapping[el.tagName.toLowerCase()] ?? '', + left: rect.left + scrollX, + width: rect.width, + top: (side === 'top' ? rect.top - size : rect.bottom) + scrollY, + height: size + }); + } + } + + return resolveOverlaps(entries); +} + +// The tag of the element across the margin gap from `el` in the given +// direction. Climbs to ancestors when there's no sibling (e.g. the last

+// inside an

  • borders whatever follows the enclosing list), stopping at the +// prose root. +function adjacentTag(el: Element, direction: 'previous' | 'next', root: Element): string { + let current: Element | null = el; + while (current && current !== root) { + let sibling: Element | null = + direction === 'next' ? current.nextElementSibling : current.previousElementSibling; + if (sibling) { + return sibling.tagName.toLowerCase(); + } + current = current.parentElement; + } + return '∅'; +} + +// Group margins that overlap (sharing both horizontal and vertical space). The +// tallest in each group becomes the red box; the rest become blue lines. +function resolveOverlaps(entries: Omit[]): MarginBox[] { + let n = entries.length; + let parent = entries.map((_, i) => i); + let find = (i: number): number => (parent[i] === i ? i : (parent[i] = find(parent[i]))); + let union = (a: number, b: number) => { + parent[find(a)] = find(b); + }; + + let overlaps = (a: (typeof entries)[number], b: (typeof entries)[number]) => { + let horizontal = a.left < b.left + b.width && b.left < a.left + a.width; + let vertical = a.top < b.top + b.height && b.top < a.top + a.height; + return horizontal && vertical; + }; + + for (let i = 0; i < n; i++) { + for (let j = i + 1; j < n; j++) { + if (overlaps(entries[i], entries[j])) { + union(i, j); + } + } + } + + let groups = new Map(); + for (let i = 0; i < n; i++) { + let root = find(i); + let group = groups.get(root) ?? []; + group.push(entries[i]); + groups.set(root, group); + } + + let result: MarginBox[] = []; + let seen = new Set(); + for (let group of groups.values()) { + // Tallest margin first wins as the red box. + group.sort((a, b) => b.size - a.size); + // Only show one box per unique combination of selectors (e.g. every + // `.prose p` followed by `.prose hr` collapses to a single representative). + let key = group + .map(e => `${e.selector}|${e.side}`) + .sort() + .join(' >> '); + if (seen.has(key)) { + continue; + } + seen.add(key); + group.forEach((entry, index) => { + result.push({...entry, type: index === 0 ? 'red' : 'blue', groupIndex: index}); + }); + } + return result; +} + +// Find the CSS selector responsible for the element's current margin on the +// given side by scanning all stylesheets for matching rules that declare it, +// then picking the cascade winner (highest specificity, last in source order). +function findSelector(el: Element, prop: 'margin-top' | 'margin-bottom'): string { + let candidates: {selector: string; specificity: number; order: number}[] = []; + let order = 0; + + let record = (selectors: string[], declaration: CSSStyleDeclaration) => { + let o = order++; + if (!declaresMargin(declaration, prop)) { + return; + } + for (let selector of selectors) { + try { + if (el.matches(selector)) { + candidates.push({selector, specificity: specificity(selector), order: o}); + } + } catch { + // Ignore selectors el.matches can't parse. + } + } + }; + + // `context` is the list of resolved selectors of the enclosing style rule, so + // nested declarations/rules can be attributed to (and matched against) their + // parent. CSS nesting splits declarations that follow a nested rule into a + // separate `CSSNestedDeclarations` sub-rule with no selector of its own. + let walk = (rules: CSSRuleList, context: string[]) => { + for (let rule of Array.from(rules)) { + let styleRule = rule as CSSStyleRule; + let grouping = rule as CSSGroupingRule; + if (styleRule.selectorText) { + let resolved = resolveSelectors(styleRule.selectorText, context); + record(resolved, styleRule.style); + if (styleRule.cssRules && styleRule.cssRules.length) { + walk(styleRule.cssRules, resolved); + } + } else if (styleRule.style && !grouping.cssRules) { + // CSSNestedDeclarations: belongs to the parent rule's selector. + record(context, styleRule.style); + } else if (grouping.cssRules) { + // @media / @supports: keep the same context. + walk(grouping.cssRules, context); + } + } + }; + + for (let sheet of Array.from(document.styleSheets)) { + let rules: CSSRuleList | null = null; + try { + rules = sheet.cssRules; + } catch { + // Cross-origin stylesheet; skip. + continue; + } + if (rules) { + walk(rules, []); + } + } + + if (candidates.length === 0) { + return '(unknown)'; + } + candidates.sort((a, b) => a.specificity - b.specificity || a.order - b.order); + return candidates[candidates.length - 1].selector; +} + +// Resolve a (possibly nested) selector list against its parent context, +// substituting `&` with the parent selector per CSS nesting semantics. +function resolveSelectors(selectorText: string, context: string[]): string[] { + let list = splitSelectorList(selectorText); + if (context.length === 0) { + return list; + } + let resolved: string[] = []; + for (let selector of list) { + for (let parent of context) { + resolved.push( + selector.includes('&') ? selector.replace(/&/g, `:is(${parent})`) : `${parent} ${selector}` + ); + } + } + return resolved; +} + +function declaresMargin(style: CSSStyleDeclaration, prop: 'margin-top' | 'margin-bottom'): boolean { + let blockSide = prop === 'margin-top' ? 'margin-block-start' : 'margin-block-end'; + return Boolean( + style.getPropertyValue(prop) || + style.getPropertyValue('margin') || + style.getPropertyValue('margin-block') || + style.getPropertyValue(blockSide) + ); +} + +// Split a selector list on top-level commas (ignoring commas inside `:is()` etc.). +function splitSelectorList(text: string): string[] { + let parts: string[] = []; + let depth = 0; + let current = ''; + for (let ch of text) { + if (ch === '(') { + depth++; + } else if (ch === ')') { + depth--; + } + if (ch === ',' && depth === 0) { + if (current.trim()) { + parts.push(current.trim()); + } + current = ''; + } else { + current += ch; + } + } + if (current.trim()) { + parts.push(current.trim()); + } + return parts; +} + +// Rough CSS specificity for a single (comma-free) selector: a=IDs, b=classes/ +// attributes/pseudo-classes, c=type/pseudo-element selectors. +function specificity(selector: string): number { + let ids = (selector.match(/#[\w-]+/g) || []).length; + let classes = (selector.match(/\.[\w-]+|\[[^\]]+\]|:[\w-]+(?:\([^)]*\))?/g) || []).length; + let types = (selector.replace(/::?[\w-]+(?:\([^)]*\))?/g, ' ').match(/[a-zA-Z][\w-]*/g) || []) + .length; + return ids * 100 + classes * 10 + types; +} 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,