From e211797f4a5a1dd651f708e6019cc193097ab6ba Mon Sep 17 00:00:00 2001 From: Reid Barber Date: Wed, 15 Apr 2026 17:47:55 -0500 Subject: [PATCH 01/12] fix headings levels in component decision tree --- .../component-decision-tree.md | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/dev/s2-docs/skills/react-spectrum-s2/component-decision-tree.md b/packages/dev/s2-docs/skills/react-spectrum-s2/component-decision-tree.md index 123400f9b7a..ed553e9f95b 100644 --- a/packages/dev/s2-docs/skills/react-spectrum-s2/component-decision-tree.md +++ b/packages/dev/s2-docs/skills/react-spectrum-s2/component-decision-tree.md @@ -1,4 +1,4 @@ -## Component Decision Tree +# Component Decision Tree If the user does not specify which component they would like to use, choose one based on the requirements. Use the following as a guide: @@ -8,7 +8,7 @@ If the user does not specify which component they would like to use, choose one - If two components could work, choose the more standard and accessible pattern. - Reach for React Aria Components plus the S2 `style` macro only as a last resort when no S2 component fits the behavior or layout, or if the user specifically asks for a custom component. -### Actions and navigation +## Actions and navigation - Use `Button` for primary or secondary calls to action and prominent actions. It can also navigate. - Use `ActionButton` for lower-emphasis actions, toolbar actions, row actions, and compact icon-led actions. @@ -19,7 +19,7 @@ If the user does not specify which component they would like to use, choose one - Use `Menu` when the menu itself is the pattern, especially if you need sections, submenus, selection, links, or a custom trigger arrangement. - Use `ActionBar` for bulk actions within a collection component. -### Choosing from options +## Choosing from options - Use `Switch` for turning a setting on or off. - Use `Checkbox` for a single independent yes or no option. @@ -32,7 +32,7 @@ If the user does not specify which component they would like to use, choose one - Use `ToggleButton` for a single pressed/unpressed control. - Use `ToggleButtonGroup` for compact formatting-style or tool-style toggles, especially if multi-select may be needed. -### Text and value input +## Text and value input - Use `TextField` for single-line plain text input. - Use `SearchField` for a search query with search-specific clear and submit behavior. @@ -48,7 +48,7 @@ If the user does not specify which component they would like to use, choose one - Use `ColorSwatchPicker` to choose from predefined colors. - Use `ColorArea`, `ColorSlider`, and `ColorWheel` for direct color manipulation. -### Collections and data views +## Collections and data views - Use `TableView` when users need rows and columns, dense comparison, sortable headers, cell-level content, editable cells, column resizing, or other tabular behaviors. - Use `ListView` for a flat list of records where each row is the main unit and may include icons, thumbnails, descriptions, and row actions. @@ -58,14 +58,14 @@ If the user does not specify which component they would like to use, choose one - Use `TableView` with expandable rows only if the tabular columns still matter after hierarchy is introduced. - Use `ListView` with `hasChildItems` and breadcrumbs for drill-in navigation when only one level is shown at a time. -### `TableView` vs `ListView` vs `TreeView` vs `CardView` +## `TableView` vs `ListView` vs `TreeView` vs `CardView` - Choose `TableView` if the user needs to compare fields across columns. - Choose `ListView` if the user needs a simple vertical list of records with optional secondary content and actions. - Choose `TreeView` if parent-child structure is the key mental model. - Choose `CardView` if preview imagery, card layouts, or gallery browsing matter more than dense comparison. -### Cards +## Cards - Use `Card` for one summarized object, not for an entire selectable collection. - Use `CardView` when many cards need keyboard navigation, selection, loading states, empty states, or bulk actions. @@ -76,7 +76,7 @@ If the user does not specify which component they would like to use, choose one - Use a preview-only `Card` for gallery tiles in a waterfall-style presentation. - Use a custom `Card` only when the object is still clearly a card but the built-in layouts do not fit the content structure. -### Structure and disclosure +## Structure and disclosure - Use `Tabs` when switching between peer sections of content and showing one panel at a time. - Use `SegmentedControl` instead of `Tabs` when switching app modes or views rather than full content panels. @@ -85,7 +85,7 @@ If the user does not specify which component they would like to use, choose one - Use `Breadcrumbs` to show navigation depth or hierarchy location. - Use `Divider` to separate adjacent groups of content. -### Overlays, help, and feedback +## Overlays, help, and feedback - Use `Tooltip` for a short description of a focusable element. Do not rely on it for essential content. - Use `ContextualHelp` for additional explanation near content, especially for non-interactive or disabled UI. @@ -97,7 +97,7 @@ If the user does not specify which component they would like to use, choose one - Use `InlineAlert` for a persistent non-modal message associated with content in the current view. - Use `Toast` for temporary global feedback after an action. -### Status, loading, media, and empty states +## Status, loading, media, and empty states - Use `Badge` for compact color-coded metadata. - Use `StatusLight` for an object's current status. @@ -110,7 +110,7 @@ If the user does not specify which component they would like to use, choose one - Use `DropZone` for drag-and-drop file or object upload targets. - Use `Form` to provide layout, submission, and validation structure for grouped fields. -### Last-resort custom components +## Last-resort custom components - Only create a custom component when no S2 component matches the required interaction pattern, or when the needed layout cannot be achieved by composing existing S2 components. - Build custom components with React Aria Components for behavior and accessibility, and the S2 `style` macro for Spectrum styling. From 4a4e7c1319f5aeb0de6f3148b305b9c07940904f Mon Sep 17 00:00:00 2001 From: Reid Barber Date: Wed, 15 Apr 2026 17:48:48 -0500 Subject: [PATCH 02/12] better illustration examples in SKILL.md --- .../skills/react-spectrum-s2/implementation-guidance.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/dev/s2-docs/skills/react-spectrum-s2/implementation-guidance.md b/packages/dev/s2-docs/skills/react-spectrum-s2/implementation-guidance.md index f770f84ba43..490c073fe83 100644 --- a/packages/dev/s2-docs/skills/react-spectrum-s2/implementation-guidance.md +++ b/packages/dev/s2-docs/skills/react-spectrum-s2/implementation-guidance.md @@ -143,8 +143,12 @@ Example illustrations: ```tsx import DropToUpload from '@react-spectrum/s2/illustrations/gradient/generic1/DropToUpload'; +import CloudUpload from '@react-spectrum/s2/illustrations/gradient/generic2/CloudUpload'; +import Warning from '@react-spectrum/s2/illustrations/linear/Warning'; + + ``` - Note that illustrations can be in a Gradient or Linear style. From b9f837e8ce13831413566688030b5da20ad6d7b8 Mon Sep 17 00:00:00 2001 From: Reid Barber Date: Wed, 15 Apr 2026 17:49:00 -0500 Subject: [PATCH 03/12] skip redirects in markdown output --- packages/dev/s2-docs/scripts/generateMarkdownDocs.mjs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/dev/s2-docs/scripts/generateMarkdownDocs.mjs b/packages/dev/s2-docs/scripts/generateMarkdownDocs.mjs index 2fcc0a6f679..e853d8b3894 100644 --- a/packages/dev/s2-docs/scripts/generateMarkdownDocs.mjs +++ b/packages/dev/s2-docs/scripts/generateMarkdownDocs.mjs @@ -3218,6 +3218,12 @@ async function main() { for (const filePath of mdxFiles) { const rawContent = fs.readFileSync(filePath, 'utf8'); + + // Skip redirect pages + if (rawContent.includes(' Date: Wed, 15 Apr 2026 17:58:49 -0500 Subject: [PATCH 04/12] fix stripped inline code from page descriptions in markdown output --- .../dev/s2-docs/scripts/generateMarkdownDocs.mjs | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/packages/dev/s2-docs/scripts/generateMarkdownDocs.mjs b/packages/dev/s2-docs/scripts/generateMarkdownDocs.mjs index e853d8b3894..b1ea749abd7 100644 --- a/packages/dev/s2-docs/scripts/generateMarkdownDocs.mjs +++ b/packages/dev/s2-docs/scripts/generateMarkdownDocs.mjs @@ -1734,21 +1734,16 @@ function remarkDocsComponentsToMarkdown() { } } } - // Use literal text content inside if present. - const textContent = (node.children || []) - .filter(c => c.type === 'text' || c.type === 'mdxText') - .map(c => c.value) - .join('') - .trim(); - - if (textContent) { + // Use children inside if present. + const descChildren = (node.children || []).filter(c => c.type !== 'mdxjsEsm'); + if (descChildren.length > 0) { if (node.type === 'mdxJsxFlowElement') { parent.children[index] = { type: 'paragraph', - children: [{type: 'text', value: textContent}] + children: descChildren }; } else { - parent.children[index] = {type: 'text', value: textContent}; + parent.children.splice(index, 1, ...descChildren); } return; } From c6572c43fcd900bceea29a9a76b4fb74be49708a Mon Sep 17 00:00:00 2001 From: Reid Barber Date: Thu, 16 Apr 2026 11:11:51 -0500 Subject: [PATCH 05/12] fix S2 Routers rendering in markdown output --- .../s2-docs/scripts/generateMarkdownDocs.mjs | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/dev/s2-docs/scripts/generateMarkdownDocs.mjs b/packages/dev/s2-docs/scripts/generateMarkdownDocs.mjs index b1ea749abd7..90e7cd822ab 100644 --- a/packages/dev/s2-docs/scripts/generateMarkdownDocs.mjs +++ b/packages/dev/s2-docs/scripts/generateMarkdownDocs.mjs @@ -50,6 +50,17 @@ const COMPONENT_SRC_ROOTS = [S2_SRC_ROOT, RAC_SRC_ROOT, INTL_SRC_ROOT]; const S2_DOCS_PAGES_ROOT = path.join(REPO_ROOT, 'packages/dev/s2-docs/pages'); const DIST_ROOT = path.join(REPO_ROOT, 'packages/dev/s2-docs/dist'); const LICENSE_COMMENT_REGEX = /^\s*\{\/\*[\s\S]*?Copyright\s+20\d{2}\s+Adobe[\s\S]*?\*\/\}\s*/; +const ROUTERS_MDX_PATH = path.join(REPO_ROOT, 'packages/dev/s2-docs/src/routers-s2.mdx'); +const ROUTERS_PLACEHOLDER_REGEX = //g; +let routersMdxCache = null; +function getRoutersMdxContent() { + if (routersMdxCache == null) { + let contents = fs.readFileSync(ROUTERS_MDX_PATH, 'utf8').replace(LICENSE_COMMENT_REGEX, ''); + contents = contents.replace(/^\s*(?:import|export)\s[^\n]*(?:\n|$)/gm, ''); + routersMdxCache = contents.trim(); + } + return routersMdxCache; +} const S2_ICON_ROOT = path.join(REPO_ROOT, 'packages/@react-spectrum/s2/s2wf-icons'); const S2_ILLUSTRATION_ROOT = path.join(REPO_ROOT, 'packages/@react-spectrum/s2/spectrum-illustrations'); @@ -1970,8 +1981,9 @@ function remarkDocsComponentsToMarkdown() { } } - if (switcherType === 'component' && exampleTitles.length > 0) { - // Each code block gets its own heading from the examples array + if (switcherType && switcherType !== 'css' && exampleTitles.length > 0) { + // For any explicit switcher type (e.g. "component", "router"), each code block + // represents a distinct example and gets its own heading from the examples array. codeChildren.forEach((codeChild, i) => { const title = exampleTitles[i] || `Example ${i + 1}`; const meta = parseCodeMeta(codeChild.meta); @@ -3219,7 +3231,15 @@ async function main() { continue; } - const mdContent = rawContent.replace(LICENSE_COMMENT_REGEX, ''); + let mdContent = rawContent.replace(LICENSE_COMMENT_REGEX, ''); + + // Inline the S2 Routers MDX content once at the bottom of the page. + // Avoids rendering the same content for every framework. + if (mdContent.includes('')) { + mdContent = mdContent.replace(ROUTERS_PLACEHOLDER_REGEX, 'See the Routers section below.'); + mdContent += '\n\n## Routers\n\n' + getRoutersMdxContent(); + } + const processor = unified() .use(remarkParse) .use(remarkMdx) From 56422c579edf90b0dc2477a2a567c31975e2d660 Mon Sep 17 00:00:00 2001 From: Reid Barber Date: Thu, 16 Apr 2026 11:14:23 -0500 Subject: [PATCH 06/12] add dnd tag for RAC dnd docs page --- packages/dev/s2-docs/pages/react-aria/dnd.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/dev/s2-docs/pages/react-aria/dnd.mdx b/packages/dev/s2-docs/pages/react-aria/dnd.mdx index 0e106a8ad85..938d212845e 100644 --- a/packages/dev/s2-docs/pages/react-aria/dnd.mdx +++ b/packages/dev/s2-docs/pages/react-aria/dnd.mdx @@ -14,6 +14,7 @@ import {PokemonGridList} from './PokemonGridList'; export const section = 'Guides'; export const description = 'How to implement drag and drop.'; +export const tags = ['dnd']; # Drag and Drop From 4212bd508d23d661f5804d01ded9949e89534ff8 Mon Sep 17 00:00:00 2001 From: Reid Barber Date: Thu, 16 Apr 2026 13:44:00 -0500 Subject: [PATCH 07/12] improve Style Macro docs page markdown output --- .../s2/style/spectrum-theme.ts | 2 +- .../s2-docs/scripts/generateMarkdownDocs.mjs | 179 ++++++++++++++---- packages/dev/s2-docs/src/styleProperties.ts | 18 +- 3 files changed, 154 insertions(+), 45 deletions(-) diff --git a/packages/@react-spectrum/s2/style/spectrum-theme.ts b/packages/@react-spectrum/s2/style/spectrum-theme.ts index a961056fd38..bfd36f451f0 100644 --- a/packages/@react-spectrum/s2/style/spectrum-theme.ts +++ b/packages/@react-spectrum/s2/style/spectrum-theme.ts @@ -1050,7 +1050,7 @@ export const style = createTheme({ userSelect: ['none', 'text', 'all', 'auto'] as const, visibility: ['visible', 'hidden', 'collapse'] as const, isolation: ['isolate', 'auto'] as const, - transformOrigin: ['center', 'top', 'top right', 'right', 'bottom right', 'bottom', 'bottom left', 'left', 'top right'] as const, + transformOrigin: ['center', 'top', 'top right', 'right', 'bottom right', 'bottom', 'bottom left', 'left'] as const, cursor: ['auto', 'default', 'pointer', 'wait', 'text', 'move', 'help', 'not-allowed', 'none', 'context-menu', 'progress', 'cell', 'crosshair', 'vertical-text', 'alias', 'copy', 'no-drop', 'grab', 'grabbing', 'all-scroll', 'col-resize', 'row-resize', 'n-resize', 'e-resize', 's-resize', 'w-resize', 'ne-resize', 'nw-resize', 'se-resize', 'ew-resize', 'ns-resize', 'nesw-resize', 'nwse-resize', 'zoom-in', 'zoom-out'] as const, resize: ['none', 'vertical', 'horizontal', 'both'] as const, scrollSnapType: ['x', 'y', 'both', 'x mandatory', 'y mandatory', 'both mandatory'] as const, diff --git a/packages/dev/s2-docs/scripts/generateMarkdownDocs.mjs b/packages/dev/s2-docs/scripts/generateMarkdownDocs.mjs index 90e7cd822ab..bbdb45be65b 100644 --- a/packages/dev/s2-docs/scripts/generateMarkdownDocs.mjs +++ b/packages/dev/s2-docs/scripts/generateMarkdownDocs.mjs @@ -82,6 +82,17 @@ const functionExamplesCache = new Map(); let tsFileIndex = null; let styleMacroDataCache = null; const styleMacroTableCache = new Map(); +const styleMacroValueSetsInjected = new WeakSet(); +// Values to render without surrounding string quotes +const BARE_VALUE_TOKENS = new Set([ + 'number', + 'string', + 'string[]', + 'boolean', + 'LinearGradient', + 'true', + 'false' +]); function getTsFileIndex() { if (tsFileIndex) { @@ -374,7 +385,9 @@ function loadStyleMacroData() { negativeSpacingProperties: scope.get('negativeSpacingProperties'), sizingProperties: scope.get('sizingProperties'), percentageProperties: scope.get('percentageProperties'), - spacingTypeValues: scope.get('spacingTypeValues') || {} + spacingTypeValues: scope.get('spacingTypeValues') || {}, + propertyValueAlias: scope.get('propertyValueAlias') || {}, + sharedValueSets: scope.get('sharedValueSets') || {} }; } catch { styleMacroDataCache = null; @@ -432,12 +445,25 @@ function getStyleMacroPropertyDefinitions(category) { }; const result = {}; + const valueAliases = data.propertyValueAlias || {}; // Process regular properties (e.g., margin, padding, display) for (const [name, rawValues] of Object.entries(data.properties[category])) { let values = Array.isArray(rawValues) ? [...rawValues] : []; const links = {}; + // If this property's values are a known shared set, collapse them to a + // single alias and skip the rest of the per-value link processing. + if (valueAliases[name]) { + result[name] = { + values: [], + additionalTypes: [valueAliases[name], ...getAdditionalTypes(name)], + links: {}, + description: data.propertyDescriptions?.[name] + }; + continue; + } + // Add MDN documentation links for specific property values if (data.mdnPropertyLinks?.[name]) { for (const [key, href] of Object.entries(data.mdnPropertyLinks[name])) { @@ -506,17 +532,15 @@ function getStyleMacroPropertyDefinitions(category) { /** * Generates a markdown table documenting style macro properties and their allowed values. * - * Example output for category 'spacing': + * Rows with identical value sets are collapsed into a single row listing all matching + * property names. Type categories (baseSpacing, negativeSpacing, etc.) are emitted as + * aliases and defined once in the Value Sets section at the top of the page. + * + * Example output for a spacing-like category: * | Property | Values | * |---------|--------| - * | margin | `0`, `4`, `8`, `12`, `baseSpacing (0, 4, 8, 12, 16, 20, 24, 28, 32)`, `number`, `lengthPercentage` | - * | marginX | `0`, `4`, `8`, `baseSpacing (...)`, `number` | - * | padding | `0`, `4`, `8`, `12`, `baseSpacing (...)` | - * - * The table includes: - * - Explicit allowed values (e.g., '0', '4', '8') - * - Type categories with their full value sets (e.g., 'baseSpacing (0, 4, 8, ...)') - * - Generic types (e.g., 'number', 'lengthPercentage') + * | `margin`, `marginTop`, `marginBottom`, `marginStart`, `marginEnd`, `marginX`, `marginY` | `auto`, `baseSpacing`, `negativeSpacing`, `lengthPercentage` | + * | `padding`, `paddingTop`, `paddingBottom`, ... | `text-to-control`, ..., `baseSpacing`, `lengthPercentage` | * * @param {string} category - Property category to generate table for (e.g., 'spacing', 'layout', 'colors') * @param {object} options - Configuration options (e.g., {sort: true}) @@ -540,10 +564,10 @@ function generateStyleMacroTable(category, {sort = true} = {}) { propertyNames.sort((a, b) => a.localeCompare(b)); } - const data = loadStyleMacroData(); - - // Build a row for each property, combining explicit values and type categories - const rows = propertyNames.map((propertyName) => { + // Build the token list for a single property, combining explicit values and type aliases. + // Type categories like `baseSpacing` are emitted as aliases; their full value sets + // are documented once in the Value Sets section at the top of the page. + const buildTokens = (propertyName) => { const def = definitions[propertyName] || {}; const values = Array.isArray(def.values) ? def.values : []; const additionalTypes = Array.isArray(def.additionalTypes) ? def.additionalTypes : []; @@ -558,9 +582,17 @@ function generateStyleMacroTable(category, {sort = true} = {}) { tokens.push(token); }; - const formatValue = (value) => `\`${String(value)}\``; + const formatValue = (value) => { + if (typeof value === 'number') { + return `\`${value}\``; + } + const str = String(value); + if (BARE_VALUE_TOKENS.has(str) || str.includes('${')) { + return `\`${str}\``; + } + return `\`'${str}'\``; + }; - // Add explicit allowed values (e.g., '0', '4', '8', 'flex', 'grid') values.forEach((value) => { if (value === undefined || value === null) { return; @@ -568,31 +600,34 @@ function generateStyleMacroTable(category, {sort = true} = {}) { addToken(formatValue(value)); }); - // Add type categories with their full value sets - // Example: baseSpacing becomes `baseSpacing (0, 4, 8, 12, 16, 20, 24, 28, 32)` additionalTypes.forEach((typeName) => { if (!typeName) { return; } - if (typeName === 'baseSpacing' && Array.isArray(data?.spacingTypeValues?.baseSpacing)) { - const list = data.spacingTypeValues.baseSpacing.map(String).join(', '); - addToken(`\`${typeName} (${list})\``); - return; - } - if (typeName === 'negativeSpacing' && Array.isArray(data?.spacingTypeValues?.negativeSpacing)) { - const list = data.spacingTypeValues.negativeSpacing.map(String).join(', '); - addToken(`\`${typeName} (${list})\``); - return; - } - addToken(formatValue(typeName)); + addToken(`\`${String(typeName)}\``); }); - // Escape pipe characters for markdown table compatibility - const valueText = (tokens.length ? tokens.join(', ') : '—').replace(/\|/g, '\\|'); - return { - name: propertyName, - values: valueText - }; + return tokens; + }; + + // Group properties by identical token signatures so shared value sets collapse into one row. + // Example: margin/marginTop/marginBottom/... all share the same tokens and merge to one row. + const groups = new Map(); + for (const name of propertyNames) { + const tokens = buildTokens(name); + const signature = tokens.join('\x1f'); + let group = groups.get(signature); + if (!group) { + group = {names: [], tokens}; + groups.set(signature, group); + } + group.names.push(name); + } + + const rows = [...groups.values()].map((group) => { + const nameCell = group.names.map((n) => `\`${n}\``).join(', '); + const valueText = (group.tokens.length ? group.tokens.join(', ') : '—').replace(/\|/g, '\\|'); + return `| ${nameCell} | ${valueText} |`; }); if (!rows.length) { @@ -600,18 +635,64 @@ function generateStyleMacroTable(category, {sort = true} = {}) { return null; } - // Build the markdown table const header = '| Property | Values |'; const separator = '|---------|--------|'; - const body = rows - .map((row) => `| \`${row.name}\` | ${row.values || '—'} |`) - .join('\n'); - - const table = `${header}\n${separator}\n${body}`; + const table = `${header}\n${separator}\n${rows.join('\n')}`; styleMacroTableCache.set(cacheKey, table); return table; } +/** + * Generates the "Value sets" markdown section that defines the named aliases + * (baseSpacing, negativeSpacing, baseColors) and the generic type names + * (lengthPercentage, number, LinearGradient) referenced throughout the property tables. + * + * Injected once on the page so the tables below can reference these names + * by alias instead of repeating their full value lists on every row. + * + * @returns {string|null} Markdown section string. + */ +function generateValueSetsMarkdown() { + const data = loadStyleMacroData(); + if (!data) { + return null; + } + + const formatSetValue = (v) => (typeof v === 'number' ? `\`${v}\`` : `\`'${v}'\``); + const baseSpacing = Array.isArray(data.spacingTypeValues?.baseSpacing) + ? data.spacingTypeValues.baseSpacing.map(formatSetValue).join(', ') + : ''; + const negativeSpacing = Array.isArray(data.spacingTypeValues?.negativeSpacing) + ? data.spacingTypeValues.negativeSpacing.map(formatSetValue).join(', ') + : ''; + + const lines = [ + '## Value sets', + '', + 'The named sets below are referenced throughout the property tables.', + 'Aliases like `baseSpacing` stand in for their full value list rather than being repeated on every row.', + '', + `- **\`baseSpacing\`** — ${baseSpacing}`, + `- **\`negativeSpacing\`** — negative counterparts of \`baseSpacing\` (${negativeSpacing})`, + '- **`baseColors`** — every Spectrum 2 color token. Includes `transparent`, `black`, `white`; the numeric scales `gray-25`–`gray-1000` and `blue`/`red`/`orange`/`yellow`/`chartreuse`/`celery`/`green`/`seafoam`/`cyan`/`indigo`/`purple`/`fuchsia`/`magenta`/`pink`/`turquoise`/`brown`/`silver`/`cinnamon` at steps `100`–`1600`; the semantic scales `accent-100`–`accent-1600`, `informative-*`, `negative-*`, `notice-*`, `positive-*`; the `transparent-white-*` and `transparent-black-*` scales; overlay colors; and high-contrast-mode system colors (`ButtonFace`, `ButtonText`, `Field`, `Highlight`, `HighlightText`, `GrayText`, `Mark`, `LinkText`, `Background`, `ButtonBorder`).', + '- **`lengthPercentage`** — a CSS `` string, e.g. `\'100px\'`, `\'50%\'`, `\'1.25rem\'`.', + '- **`number`** — a unitless numeric value. Meaning is context-dependent: for sizing / inset / margin / padding properties it is a pixel count (scaled via the Spectrum size factor); for `opacity`, `flexGrow`, `flexShrink`, `order`, `zIndex`, `lineClamp`, etc. it is passed through as-is.', + '- **`LinearGradient`** — the object produced by the `linearGradient` helper, e.g. `linearGradient(\'to bottom\', [\'gray-25\', 0], [\'gray-200\', 100])`. Used by `backgroundImage`.' + ]; + + // Append shared value-set aliases (e.g. positionKeywords) used by more than one property. + const sharedSets = data.sharedValueSets || {}; + for (const [alias, values] of Object.entries(sharedSets)) { + if (!Array.isArray(values) || !values.length) { + continue; + } + const formatted = values.map(formatSetValue).join(', '); + lines.push(`- **\`${alias}\`** — ${formatted}`); + } + + return lines.join('\n'); +} + /** * Get type text from a declaration, preferring the type node (AST) over the resolved type. */ @@ -1569,6 +1650,22 @@ function remarkRemoveImportsExports() { function remarkDocsComponentsToMarkdown() { return (tree, file) => { const relatedTypes = new Set(); + + // For the Style Macro page, inject the shared "Value sets" section above the first H2 + // so aliases (baseSpacing, baseColors, etc.) are defined before any table references them. + const filePath = file?.path || ''; + if (filePath.endsWith('style-macro.mdx') && !styleMacroValueSetsInjected.has(file)) { + styleMacroValueSetsInjected.add(file); + const valueSets = generateValueSetsMarkdown(); + if (valueSets) { + const firstH2Index = tree.children.findIndex((child) => child.type === 'heading' && child.depth === 2); + if (firstH2Index !== -1) { + const valueSetsTree = unified().use(remarkParse).parse(valueSets); + tree.children.splice(firstH2Index, 0, ...valueSetsTree.children); + } + } + } + visit(tree, ['mdxJsxFlowElement', 'mdxJsxTextElement'], (node, index, parent) => { const name = node.name; if (name === 'InstallCommand') { diff --git a/packages/dev/s2-docs/src/styleProperties.ts b/packages/dev/s2-docs/src/styleProperties.ts index 4476e264003..f247c9b0352 100644 --- a/packages/dev/s2-docs/src/styleProperties.ts +++ b/packages/dev/s2-docs/src/styleProperties.ts @@ -48,6 +48,8 @@ const baseSpacingValues = [0, 2, 4, 8, 12, 16, 20, 24, 28, 32, 36, 40, 44, 48, 5 const negativeBaseSpacingValues = [-2, -4, -8, -12, -16, -20, -24, -28, -32, -36, -40, -44, -48, -56, -64, -80, -96]; const relativeSpacingValues = ['text-to-control', 'text-to-visual', 'edge-to-text', 'pill']; const heightBaseValues = ['auto', 'full', 'min', 'max', 'fit', 'screen']; +// Position keyword set shared by `backgroundPosition` and `objectPosition`. +const positionKeywordValues = ['bottom', 'center', 'left', 'left bottom', 'left top', 'right', 'right bottom', 'right top', 'top']; const fontSize = [ 'ui-xs', 'ui-sm', 'ui', 'ui-lg', 'ui-xl', 'ui-2xl', 'ui-3xl', 'heading-2xs', 'heading-xs', 'heading-sm', 'heading', 'heading-lg', 'heading-xl', 'heading-2xl', 'heading-3xl', @@ -135,7 +137,7 @@ const dimensionsPropertyValues: {[key: string]: (string | number)[]} = { left: ['auto', 'full'], bottom: ['auto', 'full'], right: ['auto', 'full'], - aspectRatio: ['auto', 'square', 'video', 'number / number'] + aspectRatio: ['auto', 'square', 'video', '${number}/${number}'] }; const textPropertyValues: {[key: string]: (string | number)[]} = { @@ -170,7 +172,7 @@ const effectsPropertyValues: {[key: string]: (string | number)[]} = { colorScheme: ['light', 'dark', 'light dark'], // TODO: ideally would be type for LinearGradient, will need to decide if we wanna export LinearGradient backgroundImage: ['string', 'LinearGradient'], - backgroundPosition: ['bottom', 'center', 'left', 'left bottom', 'left top', 'right', 'right bottom', 'right top', 'top'], + backgroundPosition: positionKeywordValues, backgroundSize: ['auto', 'cover', 'contain'], backgroundAttachment: ['fixed', 'local', 'scroll'], backgroundClip: ['border-box', 'padding-box', 'content-box', 'text'], @@ -251,7 +253,7 @@ const miscPropertyValues: {[key: string]: (string | number)[]} = { scrollSnapStop: ['normal', 'always'], appearance: ['none', 'auto'], objectFit: ['contain', 'cover', 'fill', 'none', 'scale-down'], - objectPosition: ['bottom', 'center', 'left', 'left bottom', 'left top', 'right', 'right bottom', 'right top', 'top'], + objectPosition: positionKeywordValues, willChange: ['auto', 'scroll-position', 'contents', 'transform'], zIndex: ['number'], disableTapHighlight: ['true'], @@ -508,6 +510,16 @@ export const spacingTypeValues = { negativeSpacing: negativeBaseSpacingValues }; +// Maps a property name to a named value-set alias. +export const propertyValueAlias: {[key: string]: string} = { + backgroundPosition: 'positionKeywords', + objectPosition: 'positionKeywords' +}; + +export const sharedValueSets: {[key: string]: (string | number)[]} = { + positionKeywords: positionKeywordValues +}; + // a mapping of value to mdn links that should be replaced in place const mdnTypeLinks: {[key: string]: string} = { 'string': 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String', From 9c78fcbaa3386f00fe2945d2f56e451c56756b3c Mon Sep 17 00:00:00 2001 From: Reid Barber Date: Thu, 16 Apr 2026 13:44:24 -0500 Subject: [PATCH 08/12] remove error pages from output --- packages/dev/s2-docs/scripts/generateMarkdownDocs.mjs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/dev/s2-docs/scripts/generateMarkdownDocs.mjs b/packages/dev/s2-docs/scripts/generateMarkdownDocs.mjs index bbdb45be65b..1bf69443bbe 100644 --- a/packages/dev/s2-docs/scripts/generateMarkdownDocs.mjs +++ b/packages/dev/s2-docs/scripts/generateMarkdownDocs.mjs @@ -3328,6 +3328,11 @@ async function main() { continue; } + // Skip error pages + if (path.basename(filePath) === 'error.mdx') { + continue; + } + let mdContent = rawContent.replace(LICENSE_COMMENT_REGEX, ''); // Inline the S2 Routers MDX content once at the bottom of the page. From e4bac2f2a194a6ffa3db997b1806f202421e187b Mon Sep 17 00:00:00 2001 From: Reid Barber Date: Thu, 16 Apr 2026 14:33:39 -0500 Subject: [PATCH 09/12] better JSDoc for focusRing (mention passing isFocusVisible) --- packages/@react-spectrum/s2/style/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/@react-spectrum/s2/style/index.ts b/packages/@react-spectrum/s2/style/index.ts index ad6120f7d6c..0d263885fe8 100644 --- a/packages/@react-spectrum/s2/style/index.ts +++ b/packages/@react-spectrum/s2/style/index.ts @@ -62,6 +62,8 @@ export function fontRelative(base: number, baseFontSize = 14): `[${string}]` { /** * Returns consistent Spectrum focus ring outline styles for interactive components. + * + * Note: Requires `isFocusVisible` to be passed into the style call that uses this. * * @example * ```tsx From 41694ef31206d563b1a1a0e643b8af2dfdf5906f Mon Sep 17 00:00:00 2001 From: Reid Barber Date: Thu, 16 Apr 2026 14:58:06 -0500 Subject: [PATCH 10/12] improve skill with guidance on Buttons with icons and text --- .../skills/react-spectrum-s2/implementation-guidance.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/dev/s2-docs/skills/react-spectrum-s2/implementation-guidance.md b/packages/dev/s2-docs/skills/react-spectrum-s2/implementation-guidance.md index 490c073fe83..b7c3da322c4 100644 --- a/packages/dev/s2-docs/skills/react-spectrum-s2/implementation-guidance.md +++ b/packages/dev/s2-docs/skills/react-spectrum-s2/implementation-guidance.md @@ -121,6 +121,14 @@ import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; See [Style Macro]({{guidesBase}}style-macro.md) for the available typography tokens and related text styling options. +## Buttons with icons and text + +When a `Button`, `ActionButton`, or `LinkButton` contains **both** an icon and a text label, the text **must** be wrapped in an S2 `` element so the component can apply the correct icon/label slot styling and spacing. Plain string children next to an icon render incorrectly. + +- Icon-only: no `` needed (provide an accessible label via `aria-label`). +- Text-only: plain string children are fine. +- Icon + text: wrap the label in ``. + ## Icons Use React Spectrum's built-in icons and illustrations. From 91cb93c5f08636a568323bf91c05c969ebba5bd0 Mon Sep 17 00:00:00 2001 From: Reid Barber Date: Thu, 16 Apr 2026 15:00:51 -0500 Subject: [PATCH 11/12] improve skill with guidance to avoid concatenating class names from the style macro --- .../implementation-guidance.md | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/packages/dev/s2-docs/skills/react-spectrum-s2/implementation-guidance.md b/packages/dev/s2-docs/skills/react-spectrum-s2/implementation-guidance.md index b7c3da322c4..2bace92dde6 100644 --- a/packages/dev/s2-docs/skills/react-spectrum-s2/implementation-guidance.md +++ b/packages/dev/s2-docs/skills/react-spectrum-s2/implementation-guidance.md @@ -87,6 +87,39 @@ const styles = style({
``` +Do not concatenate class names from the `style` macro: + +The `style` macro returns an opaque class name string that encodes style precedence. Concatenating it with other class names (via template literals, `clsx`, `classnames`, spaces, etc.) breaks the precedence system and produces incorrect or unpredictable styles. + +- Do **not** build up class names conditionally by calling `style(...)` multiple times and joining the results. +- Instead, express runtime decisions inside a **single** `style({...})` call using the macro's runtime conditions (nested `default`/variant objects, and `is*`/`allows*` boolean conditions). +- When merging style strings together, use the `mergeStyles` runtime function. + +Bad: + +```tsx +// ❌ Concatenates two style macro results — breaks precedence. +const base = style({padding: 8, backgroundColor: 'gray-100'}); +const active = style({backgroundColor: 'accent'}); + +
+``` + +Good: + +```tsx +// ✅ One style call, runtime decision inside the macro. +const styles = style({ + padding: 8, + backgroundColor: { + default: 'gray-100', + isActive: 'accent' + } +}); + +
+``` + Note: - Base spacing values (for `margin`, `gap`, etc.): Use pixels following a 4px grid (`0`, `2`, `4`, `8`, `12`, `16`...) From d1a3a625fd083062ec5f07ac438de3f3b14a84ef Mon Sep 17 00:00:00 2001 From: Reid Barber Date: Mon, 20 Apr 2026 13:20:04 -0500 Subject: [PATCH 12/12] testing skill differ (#9947) * add skill differ * improve comment * deploy full diff + link to new/changed files * fix path * remove full diff output * comment with install links --- .circleci/build-skills.sh | 31 +++++ .circleci/config.yml | 47 +++++++ .circleci/skills-comment.js | 119 ++++++++++++++++ .circleci/skills-diff.js | 267 ++++++++++++++++++++++++++++++++++++ 4 files changed, 464 insertions(+) create mode 100755 .circleci/build-skills.sh create mode 100644 .circleci/skills-comment.js create mode 100644 .circleci/skills-diff.js diff --git a/.circleci/build-skills.sh b/.circleci/build-skills.sh new file mode 100755 index 00000000000..2c5b808216b --- /dev/null +++ b/.circleci/build-skills.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Build agent skills for the current working tree and copy the resulting +# .well-known/skills directories into $1 for later diffing. +# +# Runs the two node scripts directly (rather than via yarn) so the command +# works from a `git worktree` / `git archive` checkout that doesn't have +# its own installed node_modules — node will resolve deps by walking up to +# the nearest parent node_modules, letting us reuse a sibling checkout's +# installed deps via a symlink. +set -euo pipefail + +if [ -z "${1-}" ]; then + echo "usage: $0 " >&2 + exit 1 +fi + +DEST="$1" + +rm -rf packages/dev/s2-docs/dist +node packages/dev/s2-docs/scripts/generateMarkdownDocs.mjs +node packages/dev/s2-docs/scripts/generateAgentSkills.mjs + +rm -rf "$DEST" +mkdir -p "$DEST" +for lib in s2 react-aria; do + src="packages/dev/s2-docs/dist/$lib/.well-known/skills" + if [ -d "$src" ]; then + mkdir -p "$DEST/$lib" + cp -R "$src" "$DEST/$lib/" + fi +done diff --git a/.circleci/config.yml b/.circleci/config.yml index d2cdca6d379..eccc291f377 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -469,6 +469,45 @@ jobs: paths: - 'ts-diff.txt' + skills-diff: + executor: rsp-large + steps: + - restore_cache: + key: react-spectrum-{{ .Environment.CACHE_VERSION }}-{{ .Environment.CIRCLE_SHA1 }} + + - run: + name: build agent skills (branch) + command: ./.circleci/build-skills.sh /tmp/dist/skills-branch + + - run: + name: build agent skills (main) + command: | + mkdir -p ~/.ssh + curl -L https://api.github.com/meta | jq -r '.ssh_keys | .[]' | sed -e 's/^/github.com /' >> ~/.ssh/known_hosts + BRANCH_ROOT="$(pwd)" + BUILD_SKILLS="$BRANCH_ROOT/.circleci/build-skills.sh" + git worktree add --detach /tmp/main-worktree origin/main + # Reuse the branch's installed node_modules since `build-skills.sh` + # only invokes node scripts (no yarn install needed in the worktree). + ln -s "$BRANCH_ROOT/node_modules" /tmp/main-worktree/node_modules + cd /tmp/main-worktree && "$BUILD_SKILLS" /tmp/dist/skills-main + + - run: + name: diff agent skills + command: | + mkdir -p dist + node .circleci/skills-diff.js \ + /tmp/dist/skills-main /tmp/dist/skills-branch \ + > dist/skills-diff.md || true + + - store_artifacts: + path: dist/skills-diff.md + + - persist_to_workspace: + root: dist + paths: + - 'skills-diff.md' + typecheck-docs: executor: rsp-large steps: @@ -874,6 +913,7 @@ jobs: if [ $GITHUB_TOKEN ]; then node .circleci/comment.js node .circleci/api-comment.js + node .circleci/skills-comment.js fi publish-nightly: @@ -947,6 +987,12 @@ workflows: filters: branches: ignore: main + - skills-diff: + requires: + - install + filters: + branches: + ignore: main - typecheck-docs: requires: - install @@ -1052,6 +1098,7 @@ workflows: ignore: main requires: - ts-diff + - skills-diff - deploy - deploy-s3-commit - comment: diff --git a/.circleci/skills-comment.js b/.circleci/skills-comment.js new file mode 100644 index 00000000000..3f33b086ce5 --- /dev/null +++ b/.circleci/skills-comment.js @@ -0,0 +1,119 @@ +const Octokit = require('@octokit/rest'); +const fs = require('fs'); + +const octokit = new Octokit({ + auth: `token ${process.env.GITHUB_TOKEN}` +}); + +run(); + +let commentKey = ''; + +async function run() { + let pr; + // If we aren't running on a PR commit, double check if this is a branch created for a fork. If so, we'll need to + // comment the build link on the fork. + if (!process.env.CIRCLE_PULL_REQUEST) { + try { + const commit = await octokit.git.getCommit({ + owner: 'adobe', + repo: 'react-spectrum', + commit_sha: process.env.CIRCLE_SHA1 + }); + + // Check if it is a merge commit from the github "Branch from fork action" + if (commit && commit.data?.parents?.length === 2 && commit.data.message.indexOf('Merge') > -1) { + // Unfortunately listPullRequestsAssociatedWithCommit doesn't return fork prs so have to use search api + // to find the fork PR the original commit lives in + const forkHeadCommit = commit.data.parents[1].sha; + const searchRes = await octokit.search.issuesAndPullRequests({ + q: `${forkHeadCommit}+repo:adobe/react-spectrum+is:pr+is:open` + }); + + // Look for a PR that is from a fork and has a matching head commit as the current branch + const pullNumbers = searchRes.data.items.filter(i => i.pull_request !== undefined).map(j => j.number); + for (let pull_number of pullNumbers) { + const {data} = await octokit.pulls.get({ + owner: 'adobe', + repo: 'react-spectrum', + pull_number + }); + if (data && data.head.repo.full_name !== 'adobe/react-spectrum' && data.head.sha === forkHeadCommit) { + pr = pull_number; + break; + } + } + } + } catch (error) { + console.error(error); + } + } else { + pr = process.env.CIRCLE_PULL_REQUEST.split('/').pop(); + } + + if (pr != null) { + let commentId = await findDifferComment(pr); + let diff; + try { + diff = fs.readFileSync('/tmp/dist/skills-diff.md', 'utf8'); + } catch (e) { + console.log('No Agent Skills diff output to post.'); + return; + } + if (diff.trim().length > 0) { + const body = `${commentKey} +## Agent Skills Changes + +${diff} +`; + if (commentId != null) { + await octokit.issues.deleteComment({ + owner: 'adobe', + repo: 'react-spectrum', + comment_id: commentId + }); + } + await octokit.issues.createComment({ + owner: 'adobe', + repo: 'react-spectrum', + issue_number: pr, + body + }); + } else if (commentId != null) { + // No changes — delete any prior comment so the PR doesn't show stale output. + await octokit.issues.deleteComment({ + owner: 'adobe', + repo: 'react-spectrum', + comment_id: commentId + }); + } + } +} + +async function findDifferComment(pr, page = 1) { + let comments = null; + try { + comments = await octokit.issues.listComments({ + owner: 'adobe', + repo: 'react-spectrum', + issue_number: pr, + page + }); + } catch (err) { + console.log(err); + return null; + } + + let commentId; + for (let comment of comments.data) { + if (comment.body.includes(commentKey)) { + commentId = comment.id; + break; + } + } + // default results per page is 30 + if (commentId == null && comments.data.length === 30) { + commentId = await findDifferComment(pr, page + 1); + } + return commentId; +} diff --git a/.circleci/skills-diff.js b/.circleci/skills-diff.js new file mode 100644 index 00000000000..4210756d119 --- /dev/null +++ b/.circleci/skills-diff.js @@ -0,0 +1,267 @@ +#!/usr/bin/env node +// Generate a reviewer-friendly markdown diff between two agent-skill trees. +// +// Usage: +// node skills-diff.js > skills-diff.md +// +// Writes a short summary (file list with +/- counts) to stdout. Per-file +// bullets and install commands link to the deployed branch build. +const fs = require('fs'); +const path = require('path'); + +const MAX_SUMMARY_CHARS = 60000; +const TEXT_EXTS = new Set(['.md', '.json', '.txt']); + +const S2_BASE = 'https://d1pzu54gtk2aed.cloudfront.net'; +const RAC_BASE = 'https://d5iwopk28bdhl.cloudfront.net'; + +function walk(root) { + const out = new Map(); + if (!fs.existsSync(root)) { + return out; + } + const stack = [root]; + while (stack.length) { + const dir = stack.pop(); + for (const entry of fs.readdirSync(dir, {withFileTypes: true})) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + stack.push(full); + } else if (entry.isFile()) { + out.set(path.relative(root, full), full); + } + } + } + return out; +} + +function readMaybeText(p) { + const buf = fs.readFileSync(p); + // Heuristic: treat files with a NUL byte in the first 4KB as binary. + const sample = buf.slice(0, Math.min(buf.length, 4096)); + for (let i = 0; i < sample.length; i++) { + if (sample[i] === 0) { + return null; + } + } + return buf.toString('utf8'); +} + +// Minimal LCS-based unified diff — avoids any runtime deps. +function diffLines(a, b) { + const aLines = a.split('\n'); + const bLines = b.split('\n'); + const n = aLines.length; + const m = bLines.length; + const dp = Array.from({length: n + 1}, () => new Uint32Array(m + 1)); + for (let i = n - 1; i >= 0; i--) { + for (let j = m - 1; j >= 0; j--) { + if (aLines[i] === bLines[j]) { + dp[i][j] = dp[i + 1][j + 1] + 1; + } else { + dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]); + } + } + } + const out = []; + let i = 0; + let j = 0; + while (i < n && j < m) { + if (aLines[i] === bLines[j]) { + out.push(' ' + aLines[i]); + i++; + j++; + } else if (dp[i + 1][j] >= dp[i][j + 1]) { + out.push('-' + aLines[i]); + i++; + } else { + out.push('+' + bLines[j]); + j++; + } + } + while (i < n) { + out.push('-' + aLines[i++]); + } + while (j < m) { + out.push('+' + bLines[j++]); + } + return out; +} + +function countLines(s) { + if (s.length === 0) { + return 0; + } + return s.split('\n').length - (s.endsWith('\n') ? 1 : 0); +} + +function changeStats(diff) { + let added = 0; + let removed = 0; + for (const line of diff) { + const c = line.charAt(0); + if (c === '+') { + added++; + } else if (c === '-') { + removed++; + } + } + return {added, removed}; +} + +function countChanges(mainPath, branchPath) { + const ext = path.extname(branchPath || mainPath).toLowerCase(); + if (!TEXT_EXTS.has(ext)) { + return null; + } + if (mainPath && !branchPath) { + const a = readMaybeText(mainPath); + if (a === null) {return null;} + return {added: 0, removed: countLines(a)}; + } + if (!mainPath && branchPath) { + const b = readMaybeText(branchPath); + if (b === null) {return null;} + return {added: countLines(b), removed: 0}; + } + const a = readMaybeText(mainPath); + const b = readMaybeText(branchPath); + if (a === null || b === null) {return null;} + return changeStats(diffLines(a, b)); +} + +// GitHub renders $\color{...}$ math spans in comments, which is the only +// reliable way to get inline red/green text without images. +function colorCounts(counts) { + if (!counts) { + return ''; + } + const parts = []; + if (counts.added) { + parts.push(`$\\color{green}{+${counts.added}}$`); + } + if (counts.removed) { + parts.push(`$\\color{red}{-${counts.removed}}$`); + } + return parts.join(' '); +} + +// Map "s2/skills/" / "react-aria/skills/" (the layout produced +// by build-skills.sh) to a cloudfront URL on the branch build. +function fileUrl(relPath, sha) { + if (!sha) {return null;} + const parts = relPath.split(path.sep); + const lib = parts[0]; + const rest = parts.slice(1).join('/'); + // `rest` starts with "skills/...", the deploy lands it under /.well-known/ + let base; + if (lib === 's2') { + base = S2_BASE; + } else if (lib === 'react-aria') { + base = RAC_BASE; + } else { + return null; + } + return `${base}/pr/${sha}/.well-known/${rest}`; +} + +function bulletLabel(relPath, sha) { + const url = fileUrl(relPath, sha); + return url ? `[\`${relPath}\`](${url})` : `\`${relPath}\``; +} + +function classify(mainFiles, branchFiles) { + const added = []; + const removed = []; + const modified = []; + const allKeys = new Set([...mainFiles.keys(), ...branchFiles.keys()]); + for (const key of [...allKeys].sort()) { + const mainPath = mainFiles.get(key); + const branchPath = branchFiles.get(key); + if (mainPath && !branchPath) { + removed.push(key); + } else if (!mainPath && branchPath) { + added.push(key); + } else { + const a = fs.readFileSync(mainPath); + const b = fs.readFileSync(branchPath); + if (!a.equals(b)) { + modified.push(key); + } + } + } + return {added, removed, modified}; +} + +function renderSummary({added, removed, modified, mainFiles, branchFiles, sha}) { + const parts = []; + + const listSection = (label, files, linkable, getCounts) => { + if (!files.length) {return;} + parts.push(`
${label} (${files.length})`); + parts.push(''); + for (const f of files) { + const labelMd = linkable ? bulletLabel(f, sha) : `\`${f}\``; + const counts = getCounts ? getCounts(f) : null; + const suffix = counts ? ` ${colorCounts(counts)}` : ''; + parts.push(`- ${labelMd}${suffix}`); + } + parts.push(''); + parts.push('
'); + parts.push(''); + }; + + listSection('Added', added, true, f => countChanges(null, branchFiles.get(f))); + listSection('Removed', removed, false); + listSection('Modified', modified, true, f => countChanges(mainFiles.get(f), branchFiles.get(f))); + + if (sha) { + parts.push('
Install'); + parts.push(''); + parts.push('React Spectrum S2:'); + parts.push(''); + parts.push('```'); + parts.push(`npx skills add ${S2_BASE}/pr/${sha}/`); + parts.push('```'); + parts.push(''); + parts.push('React Aria:'); + parts.push(''); + parts.push('```'); + parts.push(`npx skills add ${RAC_BASE}/pr/${sha}/`); + parts.push('```'); + parts.push(''); + parts.push('
'); + parts.push(''); + } + + let out = parts.join('\n'); + if (out.length > MAX_SUMMARY_CHARS) { + out = out.slice(0, MAX_SUMMARY_CHARS) + + '\n\n_… output truncated to fit GitHub comment size limit._\n'; + } + return out; +} + +function main() { + const [mainDir, branchDir] = process.argv.slice(2); + if (!mainDir || !branchDir) { + console.error('usage: skills-diff.js '); + process.exit(1); + } + + const mainFiles = walk(mainDir); + const branchFiles = walk(branchDir); + const {added, removed, modified} = classify(mainFiles, branchFiles); + + if (added.length === 0 && removed.length === 0 && modified.length === 0) { + process.stdout.write(''); + return; + } + + const sha = process.env.CIRCLE_SHA1 || ''; + process.stdout.write(renderSummary({ + added, removed, modified, mainFiles, branchFiles, sha + })); +} + +main();