diff --git a/packages/.storybook/components/Elements/DocsPage.tsx b/packages/.storybook/components/Elements/DocsPage.tsx
new file mode 100644
index 0000000000..8ba1ef8a41
--- /dev/null
+++ b/packages/.storybook/components/Elements/DocsPage.tsx
@@ -0,0 +1,54 @@
+import { Tab, TabList, TabPanel, TabPanels, Tabs } from '@skillsoft/gamut';
+import {
+ Controls,
+ Description,
+ Primary,
+ Stories,
+ Subtitle,
+ Title,
+ useOf,
+} from '@storybook/addon-docs/blocks';
+
+import { isSystemPropCategory } from '../../systemProps';
+
+/**
+ * Storybook's default autodocs page, with system props moved out of the main
+ * props table into their own tab, and the primary story shown only at the top
+ * instead of again under Stories.
+ */
+export const DocsPage: React.FC = () => {
+ const { csfFile, preparedMeta } = useOf('meta', ['meta']);
+ const isSingleStory = Object.keys(csfFile.stories).length === 1;
+ const systemProps = Object.entries(preparedMeta.argTypes)
+ .filter(([, argType]) => isSystemPropCategory(argType.table?.category))
+ .map(([name]) => name);
+
+ return (
+ <>
+
+
+
+ {isSingleStory && }
+
+ {systemProps.length > 0 ? (
+
+
+ Props
+ System props
+
+
+
+
+
+
+
+
+
+
+ ) : (
+
+ )}
+ {!isSingleStory && }
+ >
+ );
+};
diff --git a/packages/.storybook/main.ts b/packages/.storybook/main.ts
index 1e91b1f0ea..14253cdc63 100644
--- a/packages/.storybook/main.ts
+++ b/packages/.storybook/main.ts
@@ -51,6 +51,12 @@ const config: StorybookConfig = {
typescript: {
reactDocgen: 'react-docgen-typescript',
reactDocgenTypescriptOptions: {
+ /*
+ * Needs `strictFunctionTypes: false` (unlike the root tsconfig), or
+ * `variance.compose(...)` fails to type-check and composed system props
+ * drop out of the props tables.
+ */
+ tsconfigPath: resolve(__dirname, '../gamut/tsconfig.json'),
skipChildrenPropWithoutDoc: false,
shouldRemoveUndefinedFromOptional: true,
propFilter: (prop) => {
diff --git a/packages/.storybook/preview.ts b/packages/.storybook/preview.ts
index b771e46bbd..93da0ea3e7 100644
--- a/packages/.storybook/preview.ts
+++ b/packages/.storybook/preview.ts
@@ -3,9 +3,12 @@ import { Preview } from '@storybook/react';
import theme from './theming/GamutTheme';
import { withEmotion } from './theming/GamutThemeProvider';
import { DocsContainer } from './components/Elements/DocsContainer';
+import { DocsPage } from './components/Elements/DocsPage';
import { breakpoints } from '@skillsoft/gamut-styles';
+import { enhanceSystemProps } from './systemProps';
const preview: Preview = {
+ argTypesEnhancers: [enhanceSystemProps],
parameters: {
a11y: {
test: 'error',
@@ -21,6 +24,7 @@ const preview: Preview = {
},
docs: {
container: DocsContainer,
+ page: DocsPage,
theme,
},
viewport: {
diff --git a/packages/.storybook/systemProps.ts b/packages/.storybook/systemProps.ts
new file mode 100644
index 0000000000..3ae6f55f5c
--- /dev/null
+++ b/packages/.storybook/systemProps.ts
@@ -0,0 +1,161 @@
+import { coreTheme, system } from '@skillsoft/gamut-styles';
+import type {
+ ArgTypesEnhancer,
+ StrictInputType,
+} from 'storybook/internal/types';
+
+interface SystemPropConfig {
+ property?: unknown;
+ properties?: unknown;
+ scale?: unknown;
+}
+
+interface SystemPropGroup {
+ propNames: readonly string[];
+ config: Record;
+}
+
+// The groups overlap (`space` contains `margin`/`padding`; flex and grid item
+// props are also in `layout`), so the first group listing a prop wins.
+const categoryGroups: [string, SystemPropGroup[]][] = [
+ ['Spacing', [system.space]],
+ ['Flex & Grid', [system.flex, system.grid]],
+ ['Layout', [system.layout]],
+ ['Positioning', [system.positioning]],
+ ['Typography', [system.typography]],
+ ['Color', [system.color]],
+ ['Background', [system.background]],
+ ['Border', [system.border]],
+ ['Shadow', [system.shadow]],
+ ['List', [system.list]],
+];
+
+const systemProps = new Map<
+ string,
+ { category: string; config: SystemPropConfig }
+>();
+categoryGroups.forEach(([category, groups]) => {
+ groups.forEach(({ propNames, config }) => {
+ propNames.forEach((prop) => {
+ if (!systemProps.has(prop)) {
+ systemProps.set(prop, { category, config: config[prop] });
+ }
+ });
+ });
+});
+
+const categoryOrder = categoryGroups.map(([category]) => category);
+const categoryRank = (category: string | undefined) =>
+ category === undefined ? -1 : categoryOrder.indexOf(category);
+
+export const isSystemPropCategory = (category: string | undefined) =>
+ categoryRank(category) >= 0;
+
+const themeScales = coreTheme as unknown as Record<
+ string,
+ Record | undefined
+>;
+
+// Token names come from the Core theme; other themes may define more or fewer.
+const getScaleTokens = ({ scale }: SystemPropConfig) =>
+ typeof scale === 'string' ? Object.keys(themeScales[scale] ?? {}) : [];
+
+const toKebabCase = (name: string) =>
+ name.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`);
+
+type PropertyMode = 'physical' | 'logical';
+
+// Property names, lists, and list items can each be a `{ physical, logical }` pair.
+const resolveMode = (value: unknown, mode: PropertyMode): unknown =>
+ typeof value === 'object' && value !== null && 'physical' in value
+ ? (value as Record)[mode]
+ : value;
+
+const getCssProperties = (
+ { property, properties }: SystemPropConfig,
+ mode: PropertyMode
+) => {
+ const resolved = resolveMode(properties ?? property, mode);
+ return (Array.isArray(resolved) ? resolved : [resolved])
+ .map((name) => resolveMode(name, mode))
+ .filter((name): name is string => typeof name === 'string')
+ .map(toKebabCase);
+};
+
+const describeCssProperties = (config: SystemPropConfig) => {
+ const format = (names: string[]) =>
+ names.map((name) => `\`${name}\``).join(', ');
+ const physical = getCssProperties(config, 'physical');
+ const logical = getCssProperties(config, 'logical');
+ return physical.join() === logical.join()
+ ? `CSS: ${format(physical)}`
+ : `CSS: ${format(physical)} (logical: ${format(logical)})`;
+};
+
+const MAX_INLINE_TOKENS = 12;
+
+const getTypeSummary = (config: SystemPropConfig, tokens: string[]) => {
+ if (tokens.length === 0) return { summary: 'CSS value' };
+ if (tokens.length <= MAX_INLINE_TOKENS)
+ return { summary: tokens.join(' | ') };
+ return { summary: `theme.${config.scale}`, detail: tokens.join(' | ') };
+};
+
+/*
+ * Components can declare their own props with system prop names, like a numeric
+ * `zIndex`. Docgen types real system props as `Scale<...>`, or, where two prop
+ * groups both define a prop, as csstype's open-ended `(string & {})` union.
+ */
+const hasSystemPropType = ({ table }: StrictInputType) => {
+ const docgenType = table?.type?.summary;
+ return (
+ docgenType !== undefined &&
+ (docgenType.startsWith('Scale<') || docgenType.includes('(string & {})'))
+ );
+};
+
+const enhanceSystemProp = (
+ argType: StrictInputType,
+ { category, config }: { category: string; config: SystemPropConfig }
+): StrictInputType => {
+ const tokens = getScaleTokens(config);
+ return {
+ ...argType,
+ description: argType.description || describeCssProperties(config),
+ control: argType.control ?? { type: tokens.length > 0 ? 'select' : 'text' },
+ ...(tokens.length > 0 && !argType.options && { options: tokens }),
+ table: {
+ ...argType.table,
+ category: argType.table?.category ?? category,
+ type: getTypeSummary(config, tokens),
+ },
+ };
+};
+
+/**
+ * Makes system props readable in the props table: groups them by prop family
+ * below the component's own props, summarizes their types as the theme tokens
+ * they accept, and gives them select or text controls. Anything set in a
+ * story's `argTypes` takes precedence. The table renders categories in
+ * first-seen order, so entries are sorted to keep the sections in the same
+ * order on every component. Storybook moves props with story args to the front
+ * after this runs, so a story that sets a system prop arg shows that section
+ * first.
+ */
+export const enhanceSystemProps: ArgTypesEnhancer = ({ argTypes }) =>
+ Object.fromEntries(
+ Object.entries(argTypes)
+ .map(([name, argType]): [string, typeof argType] => {
+ const systemProp = systemProps.get(name);
+ return [
+ name,
+ systemProp && hasSystemPropType(argType)
+ ? enhanceSystemProp(argType, systemProp)
+ : argType,
+ ];
+ })
+ .sort(
+ ([, a], [, b]) =>
+ categoryRank(a.table?.category) - categoryRank(b.table?.category)
+ )
+ );