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.
+
+
Token
Role
Light value
Used by
color-background-base
Page canvas
gray-50
Body, layouts
color-text-primary
Default copy
gray-900
Text, Heading
color-accent-default
Primary actions
blue-800
Button, Link
color-border-focus
Focus ring
blue-800
All interactive controls
color-negative-default
Errors
red-700
FieldError, 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.
+
+
+
+ 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 (
+ <>
+
+
+ >
+ );
+}
+
+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 (
+ <>
+
+
+ >
+ );
+}
+
+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,