diff --git a/apps/docsite/src/components/docs/AnchorHeading.tsx b/apps/docsite/src/components/docs/AnchorHeading.tsx new file mode 100644 index 000000000000..619d0ef378d1 --- /dev/null +++ b/apps/docsite/src/components/docs/AnchorHeading.tsx @@ -0,0 +1,98 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * A section heading that is linkable by anchor. Renders a `Heading` with a + * stable `id` plus a copy-link affordance that appears on hover/focus and + * copies the deep link (`#`) to the clipboard. + * + * Used for docs section headings so readers can link directly to a section + * (see issue: "Add section header link copy to docsite"). + */ + +import {useCallback, useState} from 'react'; +import * as stylex from '@stylexjs/stylex'; +import {Link2, Check} from 'lucide-react'; +import { + Heading, + type HeadingLevel, + type HeadingType, +} from '@astryxdesign/core/Text'; +import {Icon} from '@astryxdesign/core/Icon'; +import {IconButton} from '@astryxdesign/core/IconButton'; +import { + spacingVars, + durationVars, + easeVars, +} from '@astryxdesign/core/theme/tokens.stylex'; +import {anchorHeadingScope} from './anchorHeading.markers.stylex'; + +const styles = stylex.create({ + row: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: spacingVars['--spacing-2'], + // Clear the sticky app header (and, on mobile, the sticky on-this-page + // selector via --docs-anchor-offset) when navigated to via the anchor. + scrollMarginTop: + 'calc(var(--appshell-header-height, 0px) + var(--docs-anchor-offset, 0px) + 16px)', + }, + reveal: { + display: 'inline-flex', + flexShrink: 0, + transitionProperty: 'opacity', + transitionDuration: durationVars['--duration-fast'], + transitionTimingFunction: easeVars['--ease-standard'], + opacity: { + default: 0, + // Coarse pointers can't hover; keep the affordance visible there. + '@media (hover: none)': 1, + [stylex.when.ancestor(':hover', anchorHeadingScope)]: 1, + [stylex.when.ancestor(':focus-within', anchorHeadingScope)]: 1, + }, + }, +}); + +export interface AnchorHeadingProps { + /** Anchor id assigned to the heading row and used to build the deep link. */ + id: string; + level: HeadingLevel; + type?: HeadingType; + children: React.ReactNode; +} + +export function AnchorHeading({id, level, type, children}: AnchorHeadingProps) { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(() => { + if (typeof window === 'undefined') { + return; + } + const url = `${window.location.origin}${window.location.pathname}#${id}`; + window.history.replaceState(null, '', `#${id}`); + void navigator.clipboard?.writeText(url).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }); + }, [id]); + + return ( +
+ + {children} + + + } + onClick={handleCopy} + /> + +
+ ); +} diff --git a/apps/docsite/src/components/docs/DocPageLayout.tsx b/apps/docsite/src/components/docs/DocPageLayout.tsx index e2a917af43d4..b538fd35b835 100644 --- a/apps/docsite/src/components/docs/DocPageLayout.tsx +++ b/apps/docsite/src/components/docs/DocPageLayout.tsx @@ -5,19 +5,33 @@ * Owns the section padding, content container width, title and description * treatment, and vertical spacing. Pages supply only their unique body via * `children`. + * + * When `outline` items are provided, renders an on-this-page table of contents: + * a sticky `Outline` aside on desktop and a `Selector` jump menu on mobile. + * Outline anchors resolve to section ids that pages assign to their headings. */ -import type {ReactNode} from 'react'; +'use client'; + +import {useCallback, useMemo, useRef, useState, type ReactNode} from 'react'; import * as stylex from '@stylexjs/stylex'; import {Text, Heading} from '@astryxdesign/core/Text'; import {VStack} from '@astryxdesign/core/Layout'; import {Section} from '@astryxdesign/core/Section'; import {Divider} from '@astryxdesign/core/Divider'; -import {typeScaleVars} from '@astryxdesign/core/theme/tokens.stylex'; +import {Selector} from '@astryxdesign/core/Selector'; +import {Outline, type OutlineItem} from '@astryxdesign/core/Outline'; +import {useMediaQuery} from '@astryxdesign/core/hooks'; +import { + colorVars, + spacingVars, + typeScaleVars, +} from '@astryxdesign/core/theme/tokens.stylex'; import {layout} from '../../layout.stylex'; const styles = stylex.create({ - section: { + // Centered article when there is no outline aside (original behavior). + sectionCentered: { marginInline: 'auto', // Article body text reads larger and airier than the app default // (body = 14px / 1.43). Re-assigning the body size/leading tokens here @@ -32,22 +46,111 @@ const styles = stylex.create({ [typeScaleVars['--text-body-size']]: '1rem', // 16px [typeScaleVars['--text-body-leading']]: '1.75', // 28px line box }, + // Article that shares the row with a sticky outline aside. Mirrors the + // larger/airier article body typography from sectionCentered so pages with + // an outline keep the same readable prose. + sectionInRow: { + marginInline: 0, + flexShrink: 1, + minWidth: 0, + [typeScaleVars['--text-body-size']]: '1rem', // 16px + [typeScaleVars['--text-body-leading']]: '1.75', // 28px line box + }, + row: { + display: 'flex', + flexDirection: 'row', + alignItems: 'flex-start', + justifyContent: 'center', + gap: 32, + width: '100%', + }, + aside: { + position: 'sticky', + top: 'calc(var(--appshell-header-height, 0px) + 24px)', + alignSelf: 'flex-start', + flexShrink: 0, + width: 232, + }, + // Mobile on-this-page selector: pinned below the app header while scrolling. + // Lives directly in the article column so its sticky range spans the article; + // an opaque background + bottom border keep content readable as it scrolls + // underneath. + mobileOutline: { + position: 'sticky', + top: 'var(--appshell-header-height, 0px)', + zIndex: 1, + backgroundColor: colorVars['--color-background-surface'], + paddingBlock: spacingVars['--spacing-3'], + borderBottomWidth: 1, + borderBottomStyle: 'solid', + borderBottomColor: colorVars['--color-border'], + }, }); export function DocPageLayout({ title, description, children, + outline, }: { title: string; description?: string; children: ReactNode; + /** + * Optional on-this-page navigation items. When non-empty, an Outline aside + * (desktop) or Selector jump menu (mobile) is rendered. Each item id must + * match a heading/section element id in `children`. + */ + outline?: OutlineItem[]; }) { - return ( + const hasOutline = outline != null && outline.length > 0; + const isNarrow = useMediaQuery('(max-width: 1024px)'); + const showAside = hasOutline && !isNarrow; + const showSelector = hasOutline && isNarrow; + + const [activeId, setActiveId] = useState( + outline?.[0]?.id, + ); + + // Track the sticky mobile selector height so anchored sections can offset + // their scroll position by both the app header and the selector — otherwise + // the section title lands hidden behind the pinned selector. + const [selectorHeight, setSelectorHeight] = useState(0); + const resizeObserverRef = useRef(null); + const selectorRef = useCallback((node: HTMLDivElement | null) => { + resizeObserverRef.current?.disconnect(); + if (node == null) { + setSelectorHeight(0); + return; + } + const measure = () => setSelectorHeight(node.offsetHeight); + measure(); + if (typeof ResizeObserver !== 'undefined') { + const observer = new ResizeObserver(measure); + observer.observe(node); + resizeObserverRef.current = observer; + } + }, []); + + const selectorOptions = useMemo( + () => (outline ?? []).map(item => ({value: item.id, label: item.label})), + [outline], + ); + + const scrollToId = useCallback((id: string) => { + setActiveId(id); + const target = document.getElementById(id); + if (target != null) { + target.scrollIntoView({behavior: 'smooth', block: 'start'}); + window.history.pushState(null, '', `#${id}`); + } + }, []); + + const article = (
+ xstyle={showAside ? styles.sectionInRow : styles.sectionCentered}> @@ -58,10 +161,55 @@ export function DocPageLayout({ {description} ) : null} - + {/* When the mobile selector is shown it carries its own bottom border, + so the title divider is dropped to avoid a doubled separator. */} + {showSelector ? null : } + {showSelector ? ( +
+ +
+ ) : null} {children}
); + + if (!hasOutline) { + return article; + } + + const rowProps = stylex.props(styles.row); + + return ( +
+ {article} + {showAside ? ( + + ) : null} +
+ ); } diff --git a/apps/docsite/src/components/docs/PackageStubPage.tsx b/apps/docsite/src/components/docs/PackageStubPage.tsx index b7a2d965cb7e..e0b8aa4a0c8a 100644 --- a/apps/docsite/src/components/docs/PackageStubPage.tsx +++ b/apps/docsite/src/components/docs/PackageStubPage.tsx @@ -2,13 +2,58 @@ 'use client'; +import {useCallback, useMemo} from 'react'; +import * as stylex from '@stylexjs/stylex'; import {VStack} from '@astryxdesign/core/Layout'; -import {Text} from '@astryxdesign/core/Text'; +import {Text, Heading} from '@astryxdesign/core/Text'; import {Markdown} from '@astryxdesign/core/Markdown'; import {Divider} from '@astryxdesign/core/Divider'; +import {parseOutlineFromMarkdown} from '@astryxdesign/core/Outline'; +import {spacingVars} from '@astryxdesign/core/theme/tokens.stylex'; import {DocPageLayout} from './DocPageLayout'; import {PackageActions, type InstallStep} from './PackageActions'; +/** TOC depth cap so deeply-nested README headings don't overwhelm the outline. */ +const MAX_OUTLINE_LEVEL = 3; + +const headingStyles = stylex.create({ + // Match the Markdown block rhythm so headings aren't cramped. + major: { + marginBlockStart: spacingVars['--spacing-6'], + marginBlockEnd: spacingVars['--spacing-3'], + }, + minor: { + marginBlockStart: spacingVars['--spacing-4'], + marginBlockEnd: spacingVars['--spacing-2'], + }, +}); + +/** + * Render README headings to match the hand-authored docs: section headings use + * the display-3 scale (like ReferenceDocView), keeping the semantic h1–h6 level. + */ +function MarkdownHeading({ + level, + children, +}: { + level: 1 | 2 | 3 | 4 | 5 | 6; + children: React.ReactNode; +}) { + const isMajor = level <= 3; + return ( + + {children} + + ); +} + +const markdownComponents = {heading: MarkdownHeading}; + interface PackageStubPageProps { name: string; description?: string; @@ -56,8 +101,44 @@ export function PackageStubPage({ body = body.replace(/\n{3,}/g, '\n\n').trim(); } + // Heading outline derived from the README, in document order. The full list + // assigns ids to every rendered heading; the display list is depth-capped. + const headingItems = useMemo( + () => (body ? parseOutlineFromMarkdown(body) : []), + [body], + ); + const outline = useMemo( + () => headingItems.filter(item => item.level <= MAX_OUTLINE_LEVEL), + [headingItems], + ); + + // Assign ids to the rendered Markdown headings so the outline anchors resolve. + // A callback ref runs during commit — before the Outline's scroll-spy effect — + // so the heading elements already carry ids when scroll-spy starts observing. + const assignHeadingIds = useCallback( + (node: HTMLDivElement | null) => { + if (node == null) { + return; + } + const headings = node.querySelectorAll('h1, h2, h3, h4, h5, h6'); + headings.forEach((heading, i) => { + const item = headingItems[i]; + if (item == null) { + return; + } + heading.id = item.id; + (heading as HTMLElement).style.scrollMarginTop = + 'calc(var(--appshell-header-height, 0px) + var(--docs-anchor-offset, 0px) + 16px)'; + }); + }, + [headingItems], + ); + return ( - + 0 ? outline : undefined}> {body ? ( - - {body} - +
+ + {body} + +
) : ( No README available. diff --git a/apps/docsite/src/components/docs/ReferenceDocView.tsx b/apps/docsite/src/components/docs/ReferenceDocView.tsx index 6e9cbae01b83..ac3db9d59623 100644 --- a/apps/docsite/src/components/docs/ReferenceDocView.tsx +++ b/apps/docsite/src/components/docs/ReferenceDocView.tsx @@ -2,8 +2,9 @@ 'use client'; -import {Heading} from '@astryxdesign/core/Text'; import {VStack} from '@astryxdesign/core/Layout'; +import type {OutlineItem} from '@astryxdesign/core/Outline'; +import {AnchorHeading} from './AnchorHeading'; import {ContentBlockRenderer} from './ContentBlockRenderer'; import {BestPracticesBlock} from './BestPracticesBlock'; import {DocPageLayout} from './DocPageLayout'; @@ -12,16 +13,45 @@ import type {ReactNode} from 'react'; export type SectionOverrides = Record< string, - (section: DocSection) => ReactNode + (section: DocSection, id: string) => ReactNode >; +/** Slugify a section title into a stable, URL-safe anchor id. */ +function slugify(value: string): string { + return value + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +/** + * Build unique anchor ids for each section, deduping collisions by suffixing + * an index so every outline link resolves to exactly one element. + */ +function buildSectionIds(sections: DocSection[]): string[] { + const seen = new Map(); + return sections.map(section => { + const base = slugify(section.title) || 'section'; + const count = seen.get(base) ?? 0; + seen.set(base, count + 1); + return count === 0 ? base : `${base}-${count + 1}`; + }); +} + function isBestPracticesSection(section: DocSection): boolean { return section.content.every( b => b.type === 'list' && (b.style === 'do' || b.style === 'dont'), ); } -function BestPracticesSection({section}: {section: DocSection}) { +function BestPracticesSection({ + section, + id, +}: { + section: DocSection; + id: string; +}) { const items: {guidance: boolean; description: string}[] = []; for (const block of section.content) { if ( @@ -36,9 +66,9 @@ function BestPracticesSection({section}: {section: DocSection}) { } return ( - + {section.title} - + ); @@ -55,23 +85,31 @@ export function ReferenceDocView({ sections: DocSection[]; sectionOverrides?: SectionOverrides; }) { + const sectionIds = buildSectionIds(sections); + const outline: OutlineItem[] = sections.map((section, i) => ({ + id: sectionIds[i], + label: section.title, + level: 2, + })); + return ( - - {sections.map(section => { + + {sections.map((section, i) => { + const id = sectionIds[i]; const override = sectionOverrides?.[section.title]; return ( {override ? ( - override(section) + override(section, id) ) : isBestPracticesSection(section) ? ( - + ) : ( <> - + {section.title} - - {section.content.map((block, i) => ( - + + {section.content.map((block, blockIndex) => ( + ))} )} diff --git a/apps/docsite/src/components/docs/TokensDocView.tsx b/apps/docsite/src/components/docs/TokensDocView.tsx index b015eb2f4f73..f094b04324e5 100644 --- a/apps/docsite/src/components/docs/TokensDocView.tsx +++ b/apps/docsite/src/components/docs/TokensDocView.tsx @@ -4,8 +4,8 @@ import {Card} from '@astryxdesign/core/Card'; import {VStack} from '@astryxdesign/core/Layout'; -import {Heading} from '@astryxdesign/core/Text'; import {useTheme} from '@astryxdesign/core/theme'; +import {AnchorHeading} from './AnchorHeading'; import { ColorTokenTable, SpacingTokenTable, @@ -80,19 +80,21 @@ const TOPIC_SECTION_OVERRIDES: Record< function TokenSection({ section, + id, Table, theme, }: { section: DocSection; + id: string; Table: TableComponent; theme: TokenTableProps['theme']; }) { const prose = section.content.filter(block => block.type !== 'table'); return ( - + {section.title} - + {prose.map((block, i) => ( ))} @@ -123,11 +125,11 @@ export function TokensDocView({ const sectionOverrides: Record< string, - (section: DocSection) => React.ReactNode + (section: DocSection, id: string) => React.ReactNode > = {}; for (const [sectionTitle, Table] of Object.entries(overrideMap)) { - sectionOverrides[sectionTitle] = (section: DocSection) => ( - + sectionOverrides[sectionTitle] = (section: DocSection, id: string) => ( + ); } diff --git a/apps/docsite/src/components/docs/anchorHeading.markers.stylex.ts b/apps/docsite/src/components/docs/anchorHeading.markers.stylex.ts new file mode 100644 index 000000000000..7ac07192015f --- /dev/null +++ b/apps/docsite/src/components/docs/anchorHeading.markers.stylex.ts @@ -0,0 +1,10 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import * as stylex from '@stylexjs/stylex'; + +/** + * Scoped marker for AnchorHeading hover selectors. Reveals the copy-link + * affordance only when its own heading row is hovered/focused, so the button + * never leaks in from unrelated ancestors. + */ +export const anchorHeadingScope = stylex.defineMarker(); diff --git a/packages/cli/README.md b/packages/cli/README.md index 0d4e21bfbd5a..1f037e5217bf 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -11,7 +11,7 @@ npx astryx docs migration npx astryx template --list ``` -### Finding things: `astryx search` +## Finding things: `astryx search` When you don't know whether what you need is a component, a hook, a docs topic, or a template, search across all of them at once. Results are ranked by @@ -50,20 +50,20 @@ Options: ## Commands -| Command | Description | -| ------------- | --------------------------------------------------------------------------------------- | +| Command | Description | +| ------------- | ---------------------------------------------------------------------------------------------------- | | `init` | Initialize the design system in your project: installs packages, sets up theming, adds AI agent docs | -| `component` | List components or print detailed docs, props, usage examples, and source | -| `search` | Find components, hooks, docs, and templates in one ranked, cross-domain result set | -| `docs` | Print reference documentation (tokens, theme, color, typography, spacing, etc.) | -| `template` | Inject page or block templates into your project | -| `hook` | List hooks and print hook documentation | -| `swizzle` | Copy component source into your project for deep customization | -| `upgrade` | Run codemods to migrate between versions | -| `theme build` | Compile a defineTheme file to production CSS and JS | -| `discover` | Discover external packages and components | -| `gap-report` | Report a gap when a component doesn't meet your needs | -| `doctor` | Diagnose your XDS setup and report problems with fixes (CI-friendly via exit code) | +| `component` | List components or print detailed docs, props, usage examples, and source | +| `search` | Find components, hooks, docs, and templates in one ranked, cross-domain result set | +| `docs` | Print reference documentation (tokens, theme, color, typography, spacing, etc.) | +| `template` | Inject page or block templates into your project | +| `hook` | List hooks and print hook documentation | +| `swizzle` | Copy component source into your project for deep customization | +| `upgrade` | Run codemods to migrate between versions | +| `theme build` | Compile a defineTheme file to production CSS and JS | +| `discover` | Discover external packages and components | +| `gap-report` | Report a gap when a component doesn't meet your needs | +| `doctor` | Diagnose your XDS setup and report problems with fixes (CI-friendly via exit code) | ### Global options @@ -122,46 +122,46 @@ if (isError(result)) { ### Error codes -| Code | Meaning | -| --- | --- | -| `ERR_UNKNOWN` | Generic fallback for any error without a more specific code. | -| `ERR_UNKNOWN_COMMAND` | A top-level command name was not recognized (e.g. `astryx bogus`). | -| `ERR_UNKNOWN_SUBCOMMAND` | A subcommand under a group was not recognized (e.g. `astryx theme bogus`). | -| `ERR_INVALID_OPTION` | An unknown flag was passed, or `--json` was used on a command that doesn't support it. | -| `ERR_INVALID_ARGUMENT` | An option/argument value was rejected, or required flags were missing. | -| `ERR_MISSING_ARGUMENT` | A required positional argument was omitted (e.g. `astryx theme build` with no file). | -| `ERR_INVALID_LANG` | `--lang` was given a value outside its choices (`en`, `zh`, `dense`). | -| `ERR_INVALID_DETAIL` | `--detail` was given a value outside its choices (`full`, `compact`, `brief`). | -| `ERR_NODE_VERSION` | The running Node.js version is below the supported minimum. | -| `ERR_CORE_NOT_FOUND` | `@astryxdesign/core` could not be located (not installed / not in a monorepo). | -| `ERR_UNKNOWN_COMPONENT` | No component matched the requested name. | -| `ERR_UNKNOWN_HOOK` | No hook matched the requested name. | -| `ERR_UNKNOWN_TOPIC` | No docs topic matched the requested name. | -| `ERR_UNKNOWN_SECTION` | A docs topic exists but the requested section within it does not. | -| `ERR_UNKNOWN_CATEGORY` | A `--category` filter value did not match any known category. | -| `ERR_UNKNOWN_TEMPLATE` | No template matched the requested name. | -| `ERR_UNKNOWN_PACKAGE` | No package matched the requested name (discover). | -| `ERR_UNKNOWN_AGENT` | An unrecognized `--agent` value was passed (agent docs / init). | -| `ERR_UNKNOWN_FEATURE` | An unrecognized `--features` value was passed to `init`. | -| `ERR_UNKNOWN_CODEMOD` | A `--codemod` value did not match any registered codemod (upgrade). | -| `ERR_NOT_FOUND` | A discover/lookup query matched nothing in any package. | -| `ERR_NO_DOC` | A component exists but has no typed `.doc.mjs` file. | -| `ERR_NO_SHOWCASE` | No showcase exists for the requested component. | -| `ERR_NO_SOURCE` | No source file could be located for the component/template. | -| `ERR_INVALID_DOC` | A component's docs failed validation (malformed `.doc.mjs`). | -| `ERR_FILE_NOT_FOUND` | A required input file did not exist. | -| `ERR_FILE_EXISTS` | Refused to overwrite an existing file in non-interactive mode. | -| `ERR_PATH_TRAVERSAL` | A path escaped its allowed root, or a name contained traversal markers. | -| `ERR_WRITE_FAILED` | Writing output files failed (and was rolled back). | -| `ERR_THEME_INVALID` | A theme definition was missing a required property (e.g. `name`). | -| `ERR_THEME_LOAD` | A theme file could not be loaded / parsed into a `defineTheme` result. | -| `ERR_TEMPLATE_CONFIG` | `template.get` is not configured in `astryx.config.mjs` (fetch-by-id). | -| `ERR_TEMPLATE_GET` | A configured `template.get` threw or returned an invalid value. | -| `ERR_VERSION_DETECT` | The current `@astryxdesign/core` version could not be detected. | -| `ERR_INVALID_VERSION` | A `--from`/`--to` value was not a valid semver string. | -| `ERR_DEP_MISSING` | A required external dependency (e.g. jscodeshift) is missing. | -| `ERR_GH_CLI` | GitHub CLI (`gh`) is not installed or not authenticated. | -| `ERR_GAP_REPORT_FAILED` | Filing a gap report failed (disabled, or the integration errored). | +| Code | Meaning | +| ------------------------ | -------------------------------------------------------------------------------------- | +| `ERR_UNKNOWN` | Generic fallback for any error without a more specific code. | +| `ERR_UNKNOWN_COMMAND` | A top-level command name was not recognized (e.g. `astryx bogus`). | +| `ERR_UNKNOWN_SUBCOMMAND` | A subcommand under a group was not recognized (e.g. `astryx theme bogus`). | +| `ERR_INVALID_OPTION` | An unknown flag was passed, or `--json` was used on a command that doesn't support it. | +| `ERR_INVALID_ARGUMENT` | An option/argument value was rejected, or required flags were missing. | +| `ERR_MISSING_ARGUMENT` | A required positional argument was omitted (e.g. `astryx theme build` with no file). | +| `ERR_INVALID_LANG` | `--lang` was given a value outside its choices (`en`, `zh`, `dense`). | +| `ERR_INVALID_DETAIL` | `--detail` was given a value outside its choices (`full`, `compact`, `brief`). | +| `ERR_NODE_VERSION` | The running Node.js version is below the supported minimum. | +| `ERR_CORE_NOT_FOUND` | `@astryxdesign/core` could not be located (not installed / not in a monorepo). | +| `ERR_UNKNOWN_COMPONENT` | No component matched the requested name. | +| `ERR_UNKNOWN_HOOK` | No hook matched the requested name. | +| `ERR_UNKNOWN_TOPIC` | No docs topic matched the requested name. | +| `ERR_UNKNOWN_SECTION` | A docs topic exists but the requested section within it does not. | +| `ERR_UNKNOWN_CATEGORY` | A `--category` filter value did not match any known category. | +| `ERR_UNKNOWN_TEMPLATE` | No template matched the requested name. | +| `ERR_UNKNOWN_PACKAGE` | No package matched the requested name (discover). | +| `ERR_UNKNOWN_AGENT` | An unrecognized `--agent` value was passed (agent docs / init). | +| `ERR_UNKNOWN_FEATURE` | An unrecognized `--features` value was passed to `init`. | +| `ERR_UNKNOWN_CODEMOD` | A `--codemod` value did not match any registered codemod (upgrade). | +| `ERR_NOT_FOUND` | A discover/lookup query matched nothing in any package. | +| `ERR_NO_DOC` | A component exists but has no typed `.doc.mjs` file. | +| `ERR_NO_SHOWCASE` | No showcase exists for the requested component. | +| `ERR_NO_SOURCE` | No source file could be located for the component/template. | +| `ERR_INVALID_DOC` | A component's docs failed validation (malformed `.doc.mjs`). | +| `ERR_FILE_NOT_FOUND` | A required input file did not exist. | +| `ERR_FILE_EXISTS` | Refused to overwrite an existing file in non-interactive mode. | +| `ERR_PATH_TRAVERSAL` | A path escaped its allowed root, or a name contained traversal markers. | +| `ERR_WRITE_FAILED` | Writing output files failed (and was rolled back). | +| `ERR_THEME_INVALID` | A theme definition was missing a required property (e.g. `name`). | +| `ERR_THEME_LOAD` | A theme file could not be loaded / parsed into a `defineTheme` result. | +| `ERR_TEMPLATE_CONFIG` | `template.get` is not configured in `astryx.config.mjs` (fetch-by-id). | +| `ERR_TEMPLATE_GET` | A configured `template.get` threw or returned an invalid value. | +| `ERR_VERSION_DETECT` | The current `@astryxdesign/core` version could not be detected. | +| `ERR_INVALID_VERSION` | A `--from`/`--to` value was not a valid semver string. | +| `ERR_DEP_MISSING` | A required external dependency (e.g. jscodeshift) is missing. | +| `ERR_GH_CLI` | GitHub CLI (`gh`) is not installed or not authenticated. | +| `ERR_GAP_REPORT_FAILED` | Filing a gap report failed (disabled, or the integration errored). | ## Capability manifest (agent discovery) @@ -186,25 +186,59 @@ Shape: "version": "0.0.14", "description": "Design system CLI — components, themes, and tooling", "globalOptions": [ - {"flag": "--json", "type": "boolean", "description": "Output as typed JSON…"}, - {"flag": "--lang ", "type": "enum", "choices": ["en", "zh", "dense"]}, - {"flag": "--detail ", "type": "enum", "choices": ["full", "compact", "brief"], "default": "full"} + { + "flag": "--json", + "type": "boolean", + "description": "Output as typed JSON…", + }, + { + "flag": "--lang ", + "type": "enum", + "choices": ["en", "zh", "dense"], + }, + { + "flag": "--detail ", + "type": "enum", + "choices": ["full", "compact", "brief"], + "default": "full", + }, ], "commands": [ { "name": "component", "description": "List components or print component docs", - "arguments": [{"name": "name", "required": false, "variadic": false, "description": ""}], - "options": [{"flag": "--props", "type": "boolean", "description": "Print only the props table"}], + "arguments": [ + { + "name": "name", + "required": false, + "variadic": false, + "description": "", + }, + ], + "options": [ + { + "flag": "--props", + "type": "boolean", + "description": "Print only the props table", + }, + ], "json": true, - "responseTypes": ["component.list", "component.detail", "component.detail.props", "…"], - "examples": ["astryx component Button --props --json"] - } + "responseTypes": [ + "component.list", + "component.detail", + "component.detail.props", + "…", + ], + "examples": ["astryx component Button --props --json"], + }, // …one entry per command; subcommands (e.g. `theme build`) nest under `subcommands` ], "jsonSupported": ["component", "docs", "…"], - "responseTypes": {"component": ["component.list", "…"], "theme build": ["theme.build"]} - } + "responseTypes": { + "component": ["component.list", "…"], + "theme build": ["theme.build"], + }, + }, } ``` @@ -225,7 +259,15 @@ For the standalone manifest envelope (`type: "manifest"`), use `astryx manifest The same logic that powers `xds --json` is available as importable, type-safe functions: ```typescript -import {component, docs, discover, template, hook, search, AstryxError} from '@astryxdesign/cli/api'; +import { + component, + docs, + discover, + template, + hook, + search, + AstryxError, +} from '@astryxdesign/cli/api'; // Same result as: xds --json component Button const btn = await component('Button'); @@ -359,16 +401,16 @@ No failures — but review the ⚠ warnings above when you can. ### Checks -| Check | Status it can return | What it verifies | -| -------------------- | -------------------- | ----------------------------------------------------------- | -| Node.js version | pass / fail | Running Node meets the CLI's minimum | -| @astryxdesign/core installed | pass / fail | `@astryxdesign/core` is resolvable from the project | -| Version alignment | pass / warn / info | Installed `@astryxdesign/core` is in step with `@astryxdesign/cli` | -| Theme packages | pass / warn | An `@astryxdesign/theme-*` package is installed and a theme is wired | -| astryx.config.mjs | pass / fail / info | Config (if present) loads cleanly with a valid shape | -| AI agent docs | pass / warn / info | Agent docs exist and contain the XDS section markers | -| Peer dependencies | pass / warn / info | `@astryxdesign/core`'s peer deps (react, …) are installed | -| Package manager | info | Reports the detected package manager | +| Check | Status it can return | What it verifies | +| ---------------------------- | -------------------- | -------------------------------------------------------------------- | +| Node.js version | pass / fail | Running Node meets the CLI's minimum | +| @astryxdesign/core installed | pass / fail | `@astryxdesign/core` is resolvable from the project | +| Version alignment | pass / warn / info | Installed `@astryxdesign/core` is in step with `@astryxdesign/cli` | +| Theme packages | pass / warn | An `@astryxdesign/theme-*` package is installed and a theme is wired | +| astryx.config.mjs | pass / fail / info | Config (if present) loads cleanly with a valid shape | +| AI agent docs | pass / warn / info | Agent docs exist and contain the XDS section markers | +| Peer dependencies | pass / warn / info | `@astryxdesign/core`'s peer deps (react, …) are installed | +| Package manager | info | Reports the detected package manager | ### CI gate diff --git a/packages/core/src/Outline/Outline.doc.mjs b/packages/core/src/Outline/Outline.doc.mjs index 56cc4c024482..922729481e92 100644 --- a/packages/core/src/Outline/Outline.doc.mjs +++ b/packages/core/src/Outline/Outline.doc.mjs @@ -228,7 +228,7 @@ export const docsZh = { /** @type {import('../docs-types').TranslationDoc} */ export const docsDense = { description: - 'Document outline/table-of-contents nav with sliding indicator track. Flat items array {id,label,level}; anchor links; density variant (default/compact); uncontrolled scroll-spy via IntersectionObserver topmost-visible-heading; controlled with activeId; smooth-scroll on click.', + 'Document outline/table-of-contents nav with sliding indicator track. Flat items array {id,label,level}; anchor links; density variant (default/compact); uncontrolled scroll-spy by scroll position (last heading past its scroll-margin-top line; first item at top, last at bottom); controlled with activeId; smooth-scroll on click that pins the active item until the next manual scroll.', usage: { description: 'A table-of-contents sidebar for documentation pages, help centers, wikis, and long settings pages. Use it for navigation within a single page, not for app routes.', diff --git a/packages/core/src/Outline/Outline.test.tsx b/packages/core/src/Outline/Outline.test.tsx index 02c34aa4171b..f655e1cdf371 100644 --- a/packages/core/src/Outline/Outline.test.tsx +++ b/packages/core/src/Outline/Outline.test.tsx @@ -37,7 +37,13 @@ describe('parseOutlineFromMarkdown', () => { parseOutlineFromMarkdown( '## **Install** `@astryxdesign/core`\n\n```\n# Not a heading\n```', ), - ).toEqual([{id: 'install-astryxdesign-core', label: 'Install @astryxdesign/core', level: 2}]); + ).toEqual([ + { + id: 'install-astryxdesign-core', + label: 'Install @astryxdesign/core', + level: 2, + }, + ]); }); it('deduplicates generated ids', () => { @@ -87,7 +93,7 @@ describe('Outline', () => { ).not.toHaveAttribute('aria-current'); }); - it('smooth-scrolls and reports active id on click', async () => { + it('smooth-scrolls and defers the indicator until the scroll settles when uncontrolled', async () => { const user = userEvent.setup(); const target = document.createElement('h2'); target.id = 'install'; @@ -101,6 +107,47 @@ describe('Outline', () => { behavior: 'smooth', block: 'start', }); + // Uncontrolled: the indicator is deferred during the programmatic scroll, + // so it has not moved to the clicked item yet. + expect( + screen.getByRole('link', {name: 'Installation'}), + ).not.toHaveAttribute('aria-current', 'true'); + + // When the scroll settles, the indicator lands on the clicked item. + act(() => { + window.dispatchEvent(new Event('scrollend')); + }); + expect(onActiveIdChange).toHaveBeenCalledWith('install'); + expect(screen.getByRole('link', {name: 'Installation'})).toHaveAttribute( + 'aria-current', + 'true', + ); + + document.body.removeChild(target); + }); + + it('reports active id on click when controlled', async () => { + const user = userEvent.setup(); + const target = document.createElement('h2'); + target.id = 'install'; + document.body.appendChild(target); + const onActiveIdChange = vi.fn(); + + render( + , + ); + await user.click(screen.getByRole('link', {name: 'Installation'})); + + expect(target.scrollIntoView).toHaveBeenCalledWith({ + behavior: 'smooth', + block: 'start', + }); + // Controlled: there is no built-in scroll-spy, so the consumer owns the + // active state and must be notified on click. expect(onActiveIdChange).toHaveBeenCalledWith('install'); document.body.removeChild(target); @@ -122,11 +169,7 @@ describe('Outline', () => { it('renders with density="compact"', () => { render( - , + , ); expect(screen.getByTestId('outline-compact').className).toContain( 'compact', @@ -201,50 +244,45 @@ describe('Outline', () => { ).not.toHaveAttribute('aria-current'); }); - it('updates uncontrolled active id from IntersectionObserver', () => { - let observerCallback: IntersectionObserverCallback | undefined; - - class MockIntersectionObserver { - observe = vi.fn(); - disconnect = vi.fn(); - - constructor(callback: IntersectionObserverCallback) { - observerCallback = callback; - } - } - - vi.stubGlobal('IntersectionObserver', MockIntersectionObserver); - + it('updates uncontrolled active id from scroll position', () => { const intro = document.createElement('h2'); intro.id = 'intro'; + const install = document.createElement('h3'); + install.id = 'install'; const api = document.createElement('h3'); api.id = 'api'; - document.body.append(intro, api); + document.body.append(intro, install, api); - const onActiveIdChange = vi.fn(); - render(); + // Not at the bottom of the page. + Object.defineProperty(document.documentElement, 'scrollHeight', { + value: 4000, + configurable: true, + }); - const entry: IntersectionObserverEntry = { - target: api, - isIntersecting: true, - boundingClientRect: {top: 12} as DOMRectReadOnly, - intersectionRatio: 1, - intersectionRect: {} as DOMRectReadOnly, - rootBounds: null, - time: 0, - }; + // intro + install have scrolled above the activation line (top <= 0); + // api is still below it, so install is the last passed heading. + vi.spyOn(intro, 'getBoundingClientRect').mockReturnValue({ + top: -200, + } as DOMRect); + vi.spyOn(install, 'getBoundingClientRect').mockReturnValue({ + top: -10, + } as DOMRect); + vi.spyOn(api, 'getBoundingClientRect').mockReturnValue({ + top: 400, + } as DOMRect); - act(() => { - observerCallback?.([entry], {} as IntersectionObserver); - }); + const onActiveIdChange = vi.fn(); + // The hook resolves the active id from scroll position on mount. + render(); - expect(screen.getByRole('link', {name: 'API'})).toHaveAttribute( + expect(screen.getByRole('link', {name: 'Installation'})).toHaveAttribute( 'aria-current', 'true', ); - expect(onActiveIdChange).toHaveBeenCalledWith('api'); + expect(onActiveIdChange).toHaveBeenCalledWith('install'); document.body.removeChild(intro); + document.body.removeChild(install); document.body.removeChild(api); }); }); diff --git a/packages/core/src/Outline/Outline.tsx b/packages/core/src/Outline/Outline.tsx index 3650cc709612..d8a14a6d4b39 100644 --- a/packages/core/src/Outline/Outline.tsx +++ b/packages/core/src/Outline/Outline.tsx @@ -217,8 +217,9 @@ function getIndentStyle(level: number) { * indentation based on each heading level. Features a sliding indicator * track that animates to the active item. * - * When `activeId` is omitted, it observes heading elements by id and marks - * the topmost visible heading active. + * When `activeId` is omitted, it tracks scroll position and marks the last + * heading whose top has passed its activation line (its scroll-margin-top) + * active — defaulting to the first item at the top and the last at the bottom. * * @example * ``` @@ -246,7 +247,12 @@ export function Outline({ }: OutlineProps) { const rootRef = useRef(null); const LinkComponent = useLinkComponent(); - const [resolvedActiveId, setActiveId] = useScrollSpy({ + const isControlled = activeId !== undefined; + const { + activeId: resolvedActiveId, + setActiveId, + lockActiveId, + } = useScrollSpy({ activeId, items, onActiveIdChange, @@ -256,8 +262,9 @@ export function Outline({ const handleClick = (id: string) => (event: React.MouseEvent) => { const target = document.getElementById(id); - setActiveId(id); + // Let the browser handle modified clicks (open in new tab, etc.) and + // missing targets without touching the active state. if ( target == null || event.defaultPrevented || @@ -271,6 +278,18 @@ export function Outline({ event.preventDefault(); window.history.pushState(null, '', `#${id}`); + + // Move the indicator to the clicked item in a single step. Controlled + // consumers own the active state (notify only); uncontrolled mode pins + // the active id and suppresses scroll-spy until the next manual scroll, + // so the click is honored — even for short/last sections — and the + // indicator doesn't chase the smooth scroll through other sections. + if (isControlled) { + setActiveId(id); + } else { + lockActiveId(id); + } + target.scrollIntoView({behavior: 'smooth', block: 'start'}); }; diff --git a/packages/core/src/Outline/useScrollSpy.ts b/packages/core/src/Outline/useScrollSpy.ts index bddb997bdc38..d686cc8623ff 100644 --- a/packages/core/src/Outline/useScrollSpy.ts +++ b/packages/core/src/Outline/useScrollSpy.ts @@ -4,17 +4,39 @@ /** * @file useScrollSpy.ts - * @input Uses React, IntersectionObserver, OutlineItem type + * @input Uses React, scroll position of heading elements, OutlineItem type * @output Exports internal useScrollSpy hook * @position Internal behavior hook; consumed by Outline.tsx * + * Drives the active outline item from scroll position. On each scroll + * (rAF-throttled) it reads live heading positions and marks the last heading + * whose top has passed its activation line (its own scroll-margin-top, i.e. + * where it lands when navigated to). This is stable — it never compares stale + * cached positions — so the indicator moves monotonically instead of jumping. + * Defaults to the first item at the top and the last item at the bottom so + * short final sections still activate. + * * SYNC: When modified, update /packages/core/src/Outline/Outline.tsx */ -import {useEffect, useRef, useState} from 'react'; +import {useCallback, useEffect, useRef, useState} from 'react'; import type {OutlineItem} from './types'; -function getScrollableAncestor(element: HTMLElement | null): Element | null { +/** Keys that scroll the viewport — used to detect a manual scroll intent. */ +const SCROLL_KEYS = new Set([ + 'ArrowUp', + 'ArrowDown', + 'PageUp', + 'PageDown', + 'Home', + 'End', + ' ', + 'Spacebar', +]); + +function getScrollableAncestor( + element: HTMLElement | null, +): HTMLElement | null { let current = element?.parentElement ?? null; while (current != null) { @@ -36,6 +58,55 @@ function getScrollableAncestor(element: HTMLElement | null): Element | null { return null; } +/** + * Resolve the active heading id from current scroll position. + * + * A heading is "passed" once its top reaches its activation line — the scroll + * root's top plus the heading's own scroll-margin-top. The active heading is + * the last passed one (headings are in document order). When none have passed + * (scrolled above the first), the first item is active; at the bottom, the + * last item is active. + */ +function resolveActiveId( + items: OutlineItem[], + scrollRoot: HTMLElement | null, +): string | undefined { + if (items.length === 0) { + return undefined; + } + + const rootTop = + scrollRoot != null ? scrollRoot.getBoundingClientRect().top : 0; + + const atBottom = + scrollRoot != null + ? scrollRoot.scrollTop + scrollRoot.clientHeight >= + scrollRoot.scrollHeight - 2 + : window.innerHeight + window.scrollY >= + document.documentElement.scrollHeight - 2; + if (atBottom) { + return items[items.length - 1].id; + } + + let activeId = items[0].id; + for (const item of items) { + const element = document.getElementById(item.id); + if (element == null) { + continue; + } + const top = element.getBoundingClientRect().top; + const marginTop = + Number.parseFloat(window.getComputedStyle(element).scrollMarginTop) || 0; + + if (top <= rootTop + marginTop + 1) { + activeId = item.id; + } else { + break; + } + } + return activeId; +} + interface UseScrollSpyOptions { activeId?: string; items: OutlineItem[]; @@ -43,93 +114,95 @@ interface UseScrollSpyOptions { rootRef: React.RefObject; } +interface UseScrollSpyResult { + activeId: string | undefined; + /** Set the active id (notifies onActiveIdChange). For controlled consumers. */ + setActiveId: (id: string) => void; + /** + * Handle a click on the outline item with id `id`. Delays moving the + * indicator: scroll-spy is suppressed during the programmatic smooth scroll + * so the indicator doesn't chase it, then the indicator moves once to the + * clicked item when the scroll settles. If the user scrolls manually mid-way, + * scroll-position tracking resumes immediately instead. + */ + lockActiveId: (id: string) => void; +} + export function useScrollSpy({ activeId, items, onActiveIdChange, rootRef, -}: UseScrollSpyOptions): [string | undefined, (id: string) => void] { +}: UseScrollSpyOptions): UseScrollSpyResult { const isControlled = activeId !== undefined; const [uncontrolledActiveId, setUncontrolledActiveId] = useState< string | undefined >(items[0]?.id); - const visibleHeadingIdsRef = useRef>(new Set()); - const headingTopRef = useRef>(new Map()); const activeIdRef = useRef(activeId); + // While true, scroll-spy ignores scroll updates because a click is driving a + // programmatic scroll. Released when that scroll settles or the user scrolls. + const suppressRef = useRef(false); + const releaseSuppressionRef = useRef<(() => void) | null>(null); + // Latest scroll-position resolver, so the click handler can resume tracking + // when the user scrolls during a programmatic scroll. + const syncRef = useRef<(() => void) | null>(null); + // Keep latest items/callback in refs so the scroll listener effect doesn't + // re-subscribe on every render (items is a fresh array each render). + const itemsRef = useRef(items); + itemsRef.current = items; + const onActiveIdChangeRef = useRef(onActiveIdChange); + onActiveIdChangeRef.current = onActiveIdChange; const itemIds = items.map(item => item.id).join('\n'); activeIdRef.current = isControlled ? activeId : uncontrolledActiveId; useEffect(() => { - if (isControlled || typeof IntersectionObserver === 'undefined') { + if (isControlled || typeof window === 'undefined') { return; } - const headingElements = items - .map(item => document.getElementById(item.id)) - .filter((element): element is HTMLElement => element != null); + const scrollRoot = getScrollableAncestor(rootRef.current); + const scrollTarget: HTMLElement | Window = scrollRoot ?? window; - if (headingElements.length === 0) { - return; - } - - const visibleHeadingIds = visibleHeadingIdsRef.current; - const headingTop = headingTopRef.current; - - const setNextActiveId = (nextActiveId: string) => { - if (activeIdRef.current === nextActiveId) { + let frame = 0; + const update = () => { + frame = 0; + if (suppressRef.current) { return; } - activeIdRef.current = nextActiveId; - setUncontrolledActiveId(nextActiveId); - onActiveIdChange?.(nextActiveId); + const nextActiveId = resolveActiveId(itemsRef.current, scrollRoot); + if (nextActiveId != null && nextActiveId !== activeIdRef.current) { + activeIdRef.current = nextActiveId; + setUncontrolledActiveId(nextActiveId); + onActiveIdChangeRef.current?.(nextActiveId); + } }; - - const chooseActiveHeading = () => { - let nextActiveId: string | undefined; - let nextTop = Number.POSITIVE_INFINITY; - - for (const id of visibleHeadingIds) { - const top = headingTop.get(id) ?? Number.POSITIVE_INFINITY; - if (top < nextTop) { - nextTop = top; - nextActiveId = id; - } + const onScroll = () => { + if (frame === 0) { + frame = requestAnimationFrame(update); } + }; + + syncRef.current = update; + update(); + scrollTarget.addEventListener('scroll', onScroll, {passive: true}); + window.addEventListener('resize', onScroll, {passive: true}); - if (nextActiveId != null) { - setNextActiveId(nextActiveId); + return () => { + syncRef.current = null; + scrollTarget.removeEventListener('scroll', onScroll); + window.removeEventListener('resize', onScroll); + if (frame !== 0) { + cancelAnimationFrame(frame); } }; + }, [isControlled, itemIds, rootRef]); - const observer = new IntersectionObserver( - entries => { - for (const entry of entries) { - const id = entry.target.id; - headingTop.set(id, entry.boundingClientRect.top); - if (entry.isIntersecting) { - visibleHeadingIds.add(id); - } else { - visibleHeadingIds.delete(id); - } - } - chooseActiveHeading(); - }, - { - root: getScrollableAncestor(rootRef.current), - threshold: 0, - }, - ); - - for (const headingElement of headingElements) { - observer.observe(headingElement); - } - + // Tear down any pending suppression listeners when the Outline unmounts. + useEffect(() => { return () => { - observer.disconnect(); - visibleHeadingIds.clear(); - headingTop.clear(); + releaseSuppressionRef.current?.(); }; - }, [isControlled, itemIds, items, onActiveIdChange, rootRef]); + }, []); const setActiveId = (nextActiveId: string) => { if (!isControlled) { @@ -138,5 +211,65 @@ export function useScrollSpy({ onActiveIdChange?.(nextActiveId); }; - return [isControlled ? activeId : uncontrolledActiveId, setActiveId]; + const lockActiveId = useCallback((clickedId: string) => { + if (typeof window === 'undefined') { + setUncontrolledActiveId(clickedId); + activeIdRef.current = clickedId; + onActiveIdChangeRef.current?.(clickedId); + return; + } + + // Freeze the indicator during the programmatic smooth scroll instead of + // moving it immediately — it lands on the clicked item once the scroll + // settles, so it doesn't chase the scroll through intervening sections. + suppressRef.current = true; + // Replace any in-flight handlers from a previous click. + releaseSuppressionRef.current?.(); + + let settleTimer = 0; + const cleanup = () => { + window.removeEventListener('scrollend', onSettle); + window.removeEventListener('wheel', onManual); + window.removeEventListener('touchmove', onManual); + window.removeEventListener('keydown', onKeyDown); + if (settleTimer !== 0) { + clearTimeout(settleTimer); + settleTimer = 0; + } + releaseSuppressionRef.current = null; + }; + // Programmatic scroll finished: move the indicator to the clicked item. + const onSettle = () => { + cleanup(); + suppressRef.current = false; + setUncontrolledActiveId(clickedId); + activeIdRef.current = clickedId; + onActiveIdChangeRef.current?.(clickedId); + }; + // User scrolled mid-flight: hand control back to scroll-position tracking. + const onManual = () => { + cleanup(); + suppressRef.current = false; + syncRef.current?.(); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (SCROLL_KEYS.has(event.key)) { + onManual(); + } + }; + + window.addEventListener('scrollend', onSettle, {once: true}); + window.addEventListener('wheel', onManual, {passive: true}); + window.addEventListener('touchmove', onManual, {passive: true}); + window.addEventListener('keydown', onKeyDown); + // Fallback when scrollend is unsupported or no scroll is needed. + settleTimer = window.setTimeout(onSettle, 1200); + releaseSuppressionRef.current = cleanup; + }, []); + + return { + activeId: isControlled ? activeId : uncontrolledActiveId, + setActiveId, + lockActiveId, + }; }