diff --git a/packages/@react-spectrum/ai/chromatic/Prose.stories.tsx b/packages/@react-spectrum/ai/chromatic/Prose.stories.tsx new file mode 100644 index 00000000000..67392a5fa3f --- /dev/null +++ b/packages/@react-spectrum/ai/chromatic/Prose.stories.tsx @@ -0,0 +1,103 @@ +/* + * 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 {Button} from '@react-spectrum/s2/Button'; +import type {Meta, StoryObj} from '@storybook/react'; +import {prose} from '@react-spectrum/ai/style' with {type: 'macro'}; +import ProseExample from '../stories/prose.mdx'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; + +const meta: Meta = { + parameters: { + chromaticProvider: { + disableAnimations: true, + colorSchemes: ['light'], + locales: ['en-US'] + } + }, + title: 'S2 Chromatic/Prose' +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: () => ( +
+ +
+ ), + name: 'prose' +}; + +export const ProseOverrides: Story = { + render: () => ( +
+

The H1

+

The H2

+

The H3

+

The h3 should look the same as the h1 above because the font size was overridden.

+

+ This is a paragraph where the text size has been overridden to be bigger using the style + macro on the paragraph. +

+ +

Our RSP button should be unaffected.

+

Overriding prose styles

+

+ Each highlighted element below is matched by a prose selector with higher specificity than + the single-class override applied to it. The override still wins because prose lives in a + lower cascade layer than the style macro. +

+ +

Element after a heading

+

+ This paragraph immediately follows a heading, so prose zeroes its margin-top via{' '} + .prose :is(h1…h6, hr) + * (specificity 0,1,1). The override restores a 40px top + margin. It also contains{' '} + + inline code + {' '} + recolored past .prose code:not(pre code) (specificity 0,1,3). +

+ +

Last paragraph in a list item

+ + +

Links across every state

+

+ A{' '} + + recolored link + {' '} + overrides .prose a for every state: because a higher layer beats a lower one + regardless of specificity, the override even wins over prose’s :hover and{' '} + :active colors. +

+
+ ) +}; diff --git a/packages/@react-spectrum/ai/exports/style.ts b/packages/@react-spectrum/ai/exports/style.ts new file mode 100644 index 00000000000..8f6bf7ba7ed --- /dev/null +++ b/packages/@react-spectrum/ai/exports/style.ts @@ -0,0 +1 @@ +export {prose} from '../src/style/prose'; diff --git a/packages/@react-spectrum/ai/src/style/prose.ts b/packages/@react-spectrum/ai/src/style/prose.ts new file mode 100644 index 00000000000..1679da47af7 --- /dev/null +++ b/packages/@react-spectrum/ai/src/style/prose.ts @@ -0,0 +1,354 @@ +/* + * 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 {colorToken, getToken} from '../../../s2/style/tokens' with {type: 'macro'}; +import { + colorTokenToString, + fontFamily, + fontSize, + fontSizeCalc, + fontWeight, + lineHeight, + resolveColorToken, + space +} from '../../../s2/style/spectrum-theme' with {type: 'macro'}; + +interface MacroContext { + addAsset(asset: {type: string; content: string}): void; +} + +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'), // note: `prose` is a placeholder class name that is replaced with a hashed class name below + 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'), + 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: getToken('border-width-200'), + // Padding uses px, spacing (margin) uses rem — matching the style macro's maps. + paddingInlineStart: '12px', + marginInlineStart: space(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: getToken('border-width-100'), + 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: '100%' + }, + thead: { + backgroundColor: colorTokenToString(resolveColorToken(colorToken('gray-75'))), + // `borderTopRadius` isn't a real CSS property; the style macro expands it into + // the two top logical-corner properties, so do the same here. + borderStartStartRadius: getToken('corner-radius-medium-default'), + borderStartEndRadius: getToken('corner-radius-medium-default') + }, + th: { + paddingInline: '16px', + textAlign: 'start', + fontWeight: 'bold', + borderColor: colorTokenToString(resolveColorToken(colorToken('gray-300'))), + borderWidth: 0, + borderBottomWidth: getToken('border-width-100'), + 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' + } + }; + + // Emit the declarations (and any conditional sub-rules) for a single element, + // indented by `indent`. Nested `&`-conditions are relative to the enclosing rule. + let emitProperties = (properties: Record, indent: string) => { + let css = ''; + 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 += `${indent}${prop}: ${value.default};\n`; + } + for (let condition in value) { + // eslint-disable-next-line max-depth + if (condition === 'default') { + continue; + } + css += `${indent}${condition.startsWith(':') ? '&' : ''}${condition} { ${prop}: ${value[condition]}; }\n`; + } + } else { + css += `${indent}${prop}: ${value};\n`; + } + } + return css; + }; + + let css = '.prose {\n'; + for (let key in rules) { + let properties = rules[key]; + if (key === '.prose') { + css += emitProperties(properties, ' '); + } else { + css += ` ${key} {\n`; + css += emitProperties(properties, ' '); + css += ' }\n'; + } + } + css += '}\n'; + + let hashedRoot = toBase62(hash(css)); + css = css.replaceAll('prose', `${hashedRoot}`); + + // The prose layer is always lower priority than the style macro layers. + css = ` +@layer _.prose { + ${css} +}`; + + this?.addAsset({ + type: 'css', + content: css + }); + + return hashedRoot; +} + +// Generate a class name from a number, e.g. index within the theme. +// This maps to an alphabet containing lower case letters, upper case letters, and numbers. +// For numbers larger than 62, an underscore is prepended. +// This encoding allows easy parsing to enable runtime merging by property. +function generateName(index: number, atStart = false): string { + if (index < 26) { + // lower case letters + return String.fromCharCode(index + 97); + } + + if (index < 52) { + // upper case letters + return String.fromCharCode(index - 26 + 65); + } + + if (index < 62 && !atStart) { + // numbers + return String.fromCharCode(index - 52 + 48); + } + + return '_' + generateName(index - (atStart ? 52 : 62)); +} + +function toBase62(value: number) { + if (value === 0) { + return generateName(value, true); + } + + let res = ''; + let atStart = true; + while (value) { + let remainder = value % 62; + res += generateName(remainder, atStart); + atStart = false; + value = Math.floor((value - remainder) / 62); + } + + return res; +} + +// djb2 hash function. +// http://www.cse.yorku.ca/~oz/hash.html +function hash(v: string) { + let hash = 5381; + for (let i = 0; i < v.length; i++) { + hash = ((hash << 5) + hash + v.charCodeAt(i)) >>> 0; + } + return hash; +} + +// Text color per font category. Resolved from static token names so the values +// can be inlined at build time when `./tokens` is imported as a macro (matching +// how spectrum-theme consumes tokens); a dynamically computed token name cannot. +const fontColor = { + body: colorTokenToString(resolveColorToken(colorToken('body-color'))), + heading: colorTokenToString(resolveColorToken(colorToken('heading-color'))), + title: colorTokenToString(resolveColorToken(colorToken('title-color'))), + detail: colorTokenToString(resolveColorToken(colorToken('detail-color'))), + code: colorTokenToString(resolveColorToken(colorToken('code-color'))), + ui: colorTokenToString(resolveColorToken(colorToken('body-color'))) +} as const; + +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: fontColor[type as keyof typeof fontColor] + }; +} + +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/ai/stories/Chat.stories.tsx b/packages/@react-spectrum/ai/stories/Chat.stories.tsx index 2050caed1de..ad4d84e2084 100644 --- a/packages/@react-spectrum/ai/stories/Chat.stories.tsx +++ b/packages/@react-spectrum/ai/stories/Chat.stories.tsx @@ -14,8 +14,10 @@ import {ActionButton} from '@react-spectrum/s2/ActionButton'; import {ActionMenu} from '@react-spectrum/s2/ActionMenu'; import {AssetCard, CardPreview} from '@react-spectrum/s2/Card'; import {Chat} from '../src/Chat'; +import ChatIcon from '@react-spectrum/s2/icons/Chat'; import ChevronDown from '@react-spectrum/s2/icons/ChevronDown'; import {Content} from '@react-spectrum/s2/Content'; +import {DialogTrigger, Popover} from '@react-spectrum/s2/Popover'; import {GridList} from 'react-aria-components'; import {Image} from '@react-spectrum/s2/Image'; import {ListLayout} from 'react-stately/useVirtualizerState'; @@ -40,6 +42,7 @@ import { UserMessage } from '@react-spectrum/ai'; import type {Meta} from '@storybook/react'; +import {prose} from '../src/style/prose' with {type: 'macro'}; import {ReactNode, useRef, useState} from 'react'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; import {Text} from '@react-spectrum/s2/Text'; @@ -651,6 +654,120 @@ export function VirtualizedChat() { ); } +// A system response rendered as prose, e.g. markdown returned from the server. +// Uses the `prose` macro from S2 to style the semantic HTML elements. +function ProseResponse() { + return ( +
+

Setting up a design token pipeline

+

+ A token pipeline turns brand decisions into typed, versioned values every component can + consume. At a high level there are three layers: +

+
    +
  • + Global palette — raw values like gray-100 through{' '} + gray-900. +
  • +
  • + Semantic aliases — roles such as text-primary that point at + palette steps. +
  • +
  • + Component tokens — per-component overrides layered on top. +
  • +
+

A minimal build step reads your tokens and emits platform files:

+
+        {`import StyleDictionary from 'style-dictionary';
+
+StyleDictionary.extend({
+  source: ['tokens/**/*.json'],
+  platforms: {
+    css: {transformGroup: 'css', buildPath: 'dist/css/'}
+  }
+}).buildAllPlatforms();`}
+      
+

+ Run it after every change and components import semantic values instead of literals. See the{' '} + Spectrum design tokens docs for + the full naming conventions. +

+
+ ); +} + +type PopoverMessage = {id: number; type: 'user'; content: string} | {id: number; type: 'prose'}; + +// A Chat rendered inside an S2 Popover, where the system response is prose +// (markdown) styled with the `prose` macro. +export function ChatPopover() { + let messages: PopoverMessage[] = [ + { + id: 0, + type: 'user', + content: 'Can you explain how a design token pipeline works?' + }, + {id: 1, type: 'prose'} + ]; + + return ( + + + + + + + + {(msg: PopoverMessage) => { + if (msg.type === 'user') { + return ( + + {msg.content} + + ); + } + return ( + + + + + ); + }} + + {}}> +
+ + +
+
+
+
+
+ ); +} + function SystemMessage({ children, textValue = ' ', diff --git a/packages/@react-spectrum/ai/stories/prose.mdx b/packages/@react-spectrum/ai/stories/prose.mdx new file mode 100644 index 00000000000..d521094ea48 --- /dev/null +++ b/packages/@react-spectrum/ai/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/ai/stories/prose.stories.tsx b/packages/@react-spectrum/ai/stories/prose.stories.tsx new file mode 100644 index 00000000000..4f008187549 --- /dev/null +++ b/packages/@react-spectrum/ai/stories/prose.stories.tsx @@ -0,0 +1,542 @@ +/* + * 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 '@react-spectrum/ai/style' with {type: 'macro'}; +import ProseExample from './prose.mdx'; +// @ts-ignore +import React, {ReactNode, useRef, useState} from 'react'; +import * as spectrumTokens from '@adobe/spectrum-tokens/dist/json/variables.json'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; +import {useLayoutEffect} from 'react-aria/private/utils/useLayoutEffect'; + +const meta: Meta = { + tags: ['autodocs'], + title: 'AI/Prose' +}; + +export default meta; + +export const Example = () => ( + +
+ +
+
+); + +// Spectrum tokens applied to each prose element (see style/prose.ts). When a +// font isn't specified the element inherits `body`; when a margin isn't +// specified it collapses to `none`. +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'} +}; + +// The spectrum font token for a tag, defaulting to `body` when unspecified. +function fontToken(tag: string): string { + return componentMapping[tag]?.font ?? 'body'; +} + +// The margin category for a tag (see style/prose.ts), defaulting to `none`. +function marginCategory(tag: string): string { + return componentMapping[tag]?.margin ?? 'none'; +} + +// Each margin category resolves to a spectrum multiplier token. Body uses a +// single token for both sides; heading/title/detail split into distinct +// top/bottom tokens whose values differ (top ≈ 0.889em, bottom ≈ 0.25em), so we +// disambiguate by matching the measured margin against each token's value. +const marginTokensByCategory: Record = { + body: ['body-margin-multiplier'], + heading: ['heading-margin-top-multiplier', 'heading-margin-bottom-multiplier'], + title: ['title-margin-top-multiplier', 'title-margin-bottom-multiplier'], + detail: ['detail-margin-top-multiplier', 'detail-margin-bottom-multiplier'] +}; + +function tokenValue(name: string): number { + return (spectrumTokens as unknown as Record)[name].value; +} + +// The actual spectrum token responsible for a margin, resolved from the tag's +// category and the measured margin size (in em). Categories with no multiplier +// token (`none`, or hr's fixed `32px`) pass through unchanged. +function resolveMarginToken(tag: string, em: number): string { + let category = marginCategory(tag); + let candidates = marginTokensByCategory[category]; + if (!candidates) { + return category; + } + // Pick the candidate token whose value is closest to the measured margin. + return candidates.reduce((best, name) => + Math.abs(tokenValue(name) - em) < Math.abs(tokenValue(best) - em) ? name : best + ); +} + +// --------------------------------------------------------------------------- +// 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; + // The font tokens for each side of the pair, e.g. "heading-xl + body". + fontPair: string; + // The margin token responsible for the gap, e.g. "heading" or "none". + marginToken: string; + element: Element; + // 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); + + useLayoutEffect(() => { + 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.fontPair}) +
+
+ {box.side === 'top' ? 'margin-top' : 'margin-bottom'}: {box.em}em ({box.marginToken}) +
+
+ + ); +} + +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.fontPair}) +
+
+ {box.side === 'top' ? 'margin-top' : 'margin-bottom'}: {box.em}em ({box.marginToken}) +
+
+ + ); +} + +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 leftTag = side === 'top' ? neighbor : tag; + let rightTag = side === 'top' ? tag : neighbor; + let pair = `${leftTag} + ${rightTag}`; + // em resolves against the element's own font-size. + let em = Math.round((size / fontSize) * 1000) / 1000; + entries.push({ + side, + size, + em, + selector: findSelector(el, prop), + pair, + fontPair: `${fontToken(leftTag)} + ${fontToken(rightTag)}`, + // The margin belongs to `el`, the tag on this side of the gap. + marginToken: resolveMarginToken(tag, em), + element: el, + 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/__tests__/style-macro.test.js b/packages/@react-spectrum/s2/style/__tests__/style-macro.test.js index 466abed159f..5d080160efc 100644 --- a/packages/@react-spectrum/s2/style/__tests__/style-macro.test.js +++ b/packages/@react-spectrum/s2/style/__tests__/style-macro.test.js @@ -37,7 +37,7 @@ describe('style-macro', () => { }); expect(css).toMatchInlineSnapshot(` -"@layer _.a, _.b, _.c; +"@layer _.prose, _.a, _.b, _.c; @layer _.b { .Jbs151:first-child { @@ -70,7 +70,7 @@ describe('style-macro', () => { }); expect(css).toMatchInlineSnapshot(` -"@layer _.a; +"@layer _.prose, _.a; @layer _.a { ._kc151 { @@ -238,7 +238,7 @@ describe('style-macro', () => { }); expect(css).toMatchInlineSnapshot(` -"@layer _.a; +"@layer _.prose, _.a; @layer _.a { .gH151 { @@ -292,7 +292,7 @@ describe('style-macro', () => { }); expect(css).toMatchInlineSnapshot(` -"@layer _.a; +"@layer _.prose, _.a; @layer _.a { .gH151 { @@ -352,7 +352,7 @@ describe('style-macro', () => { }); expect(css).toMatchInlineSnapshot(` -"@layer _.a, _.b; +"@layer _.prose, _.a, _.b; @layer _.b.l { @media (forced-colors: active) { @@ -402,7 +402,7 @@ describe('style-macro', () => { expect(js).toMatchInlineSnapshot(`" Th151 Qh151 Sh151 Rh151 -macro-static-dvFMTb"`); expect(css).toMatchInlineSnapshot(` -"@layer _.a; +"@layer _.prose, _.a; @layer _.a { .Th151 { @@ -439,7 +439,7 @@ describe('style-macro', () => { }); expect(css).toMatchInlineSnapshot(` -"@layer _.a; +"@layer _.prose, _.a; @layer _.a { .gpQzfVb151 { @@ -464,7 +464,7 @@ describe('style-macro', () => { }); expect(css).toMatchInlineSnapshot(` -"@layer _.a; +"@layer _.prose, _.a; @layer _.a { .-FUeYm-gE151 { 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, diff --git a/packages/@react-spectrum/s2/style/style-macro.ts b/packages/@react-spectrum/s2/style/style-macro.ts index cce10985a24..f2373f1bae2 100644 --- a/packages/@react-spectrum/s2/style/style-macro.ts +++ b/packages/@react-spectrum/s2/style/style-macro.ts @@ -374,13 +374,19 @@ export function createTheme( let css = ''; // Declare layers for each priority ahead of time so the order is always correct. + // The `prose` layer is declared first so that it is always lower priority than + // the rest of the style macro layers. Reserving here will make more sense when + // prose eventually moves to live here after the API settles. + // See the prose macro in @react-spectrum/ai. css += '@layer '; let first = true; for (let i = 0; i <= usedPriorities; i++) { + if (!first) { + css += ', '; + } if (first) { first = false; - } else { - css += ', '; + css += '_.prose, '; } css += layerName(generateName(i, true)); } diff --git a/packages/dev/s2-docs/pages/react-aria/useKeyboard.mdx b/packages/dev/s2-docs/pages/react-aria/useKeyboard.mdx index 3081ade2f29..cef41a7572e 100644 --- a/packages/dev/s2-docs/pages/react-aria/useKeyboard.mdx +++ b/packages/dev/s2-docs/pages/react-aria/useKeyboard.mdx @@ -93,6 +93,7 @@ function Example() { }); let {keyboardProps: childProps} = useKeyboard({ + /*- begin highlight -*/ shortcuts: { 'Mod+s': () => add('child shortcut: Mod+s (prevents save dialog)'), 'ArrowLeft': () => add('child shortcut: ArrowLeft (prevents default, stops propagation)'), @@ -101,6 +102,7 @@ function Example() { return false; } }, + /*- end highlight -*/ onKeyDown: () => add('child onKeyDown') });