diff --git a/.storybook/main.ts b/.storybook/main.ts
index c19f279467..4ce4456890 100644
--- a/.storybook/main.ts
+++ b/.storybook/main.ts
@@ -1,4 +1,4 @@
-import {dirname} from 'path';
+import {dirname, join} from 'path';
import {fileURLToPath} from 'url';
import {VanillaExtractPlugin} from '@vanilla-extract/webpack-plugin';
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
@@ -83,6 +83,7 @@ const config: StorybookConfig = {
'./font-size-addon/preset.ts',
'./theme-selector-addon/preset.ts',
'./platform-selector-addon/preset.ts',
+ './story-code-addon/preset.ts',
],
framework: getAbsolutePath('@storybook/react-webpack5'),
@@ -100,6 +101,17 @@ const config: StorybookConfig = {
],
};
+ // Add loader for story source code
+ config.module?.rules?.push({
+ test: /-story\.tsx$/,
+ use: [
+ {
+ loader: join(import.meta.dirname, './story-code-addon/story-source-loader.js'),
+ },
+ ],
+ enforce: 'pre',
+ });
+
// Define process.env variables for browser
config.plugins?.push(
/** workaround for: https://github.com/storybookjs/storybook/issues/22287 */
diff --git a/.storybook/story-code-addon/manager.tsx b/.storybook/story-code-addon/manager.tsx
new file mode 100644
index 0000000000..38a5a1ea85
--- /dev/null
+++ b/.storybook/story-code-addon/manager.tsx
@@ -0,0 +1,315 @@
+import * as React from 'react';
+import {addons, types} from 'storybook/manager-api';
+import {AddonPanel} from 'storybook/internal/components';
+
+import type {API} from 'storybook/manager-api';
+
+const ADDON_ID = 'story-code';
+const PANEL_ID = `${ADDON_ID}/panel`;
+const PANEL_TITLE = 'Code';
+
+interface StoryCodePayload {
+ code: string;
+ focus?: [number, number];
+}
+
+interface StoryCodePanelProps {
+ api: API;
+}
+
+// Simple syntax highlighting for TypeScript/React
+const highlightCode = (code: string, focus?: [number, number]): React.ReactElement => {
+ const lines = code.split('\n');
+ let focusAnchorAssigned = false;
+
+ return (
+ <>
+ {lines.map((line, lineIndex) => {
+ const lineNumber = lineIndex + 1;
+ const isFocused =
+ focus && focus.length === 2 && lineNumber >= focus[0] && lineNumber <= focus[1];
+ // Process each line character by character to apply syntax highlighting
+ const tokens: Array<{text: string; color: string}> = [];
+ let currentToken = '';
+ let currentColor = '#d4d4d4';
+ let i = 0;
+
+ while (i < line.length) {
+ const char = line[i];
+ const remaining = line.slice(i);
+
+ // String literals (single and double quotes)
+ if (char === "'" || char === '"' || char === '`') {
+ if (currentToken) {
+ tokens.push({text: currentToken, color: currentColor});
+ currentToken = '';
+ }
+ const quote = char;
+ let stringContent = quote;
+ i++;
+ while (i < line.length && line[i] !== quote) {
+ if (line[i] === '\\' && i + 1 < line.length) {
+ stringContent += line[i] + line[i + 1];
+ i += 2;
+ } else {
+ stringContent += line[i];
+ i++;
+ }
+ }
+ if (i < line.length) stringContent += line[i];
+ tokens.push({text: stringContent, color: '#ce9178'});
+ i++;
+ continue;
+ }
+
+ // Comments
+ if (remaining.startsWith('//')) {
+ if (currentToken) {
+ tokens.push({text: currentToken, color: currentColor});
+ }
+ tokens.push({text: remaining, color: '#6a9955'});
+ break;
+ }
+
+ // Multi-line comments
+ if (remaining.startsWith('/*')) {
+ if (currentToken) {
+ tokens.push({text: currentToken, color: currentColor});
+ currentToken = '';
+ }
+ let commentContent = '/*';
+ i += 2;
+ while (i < line.length - 1 && !line.slice(i).startsWith('*/')) {
+ commentContent += line[i];
+ i++;
+ }
+ if (line.slice(i).startsWith('*/')) {
+ commentContent += '*/';
+ i += 2;
+ }
+ tokens.push({text: commentContent, color: '#6a9955'});
+ continue;
+ }
+
+ // JSX tags
+ if (char === '<' && /[A-Z]/.test(line[i + 1] || '')) {
+ if (currentToken) {
+ tokens.push({text: currentToken, color: currentColor});
+ currentToken = '';
+ }
+ currentColor = '#4ec9b0';
+ currentToken = char;
+ i++;
+ while (i < line.length && /[A-Za-z.]/.test(line[i])) {
+ currentToken += line[i];
+ i++;
+ }
+ tokens.push({text: currentToken, color: currentColor});
+ currentToken = '';
+ currentColor = '#d4d4d4';
+ continue;
+ }
+
+ // Keywords
+ if (/[a-zA-Z_]/.test(char)) {
+ currentToken += char;
+ i++;
+ while (i < line.length && /[a-zA-Z0-9_]/.test(line[i])) {
+ currentToken += line[i];
+ i++;
+ }
+
+ const keywords = [
+ 'import',
+ 'export',
+ 'from',
+ 'const',
+ 'let',
+ 'var',
+ 'function',
+ 'return',
+ 'if',
+ 'else',
+ 'for',
+ 'while',
+ 'break',
+ 'continue',
+ 'class',
+ 'interface',
+ 'type',
+ 'extends',
+ 'implements',
+ 'new',
+ 'this',
+ 'super',
+ 'static',
+ 'public',
+ 'private',
+ 'protected',
+ 'async',
+ 'await',
+ 'try',
+ 'catch',
+ 'finally',
+ 'throw',
+ 'typeof',
+ 'instanceof',
+ 'void',
+ 'null',
+ 'undefined',
+ 'true',
+ 'false',
+ 'as',
+ 'default',
+ ];
+
+ if (keywords.includes(currentToken)) {
+ tokens.push({text: currentToken, color: '#569cd6'});
+ } else if (/^[A-Z]/.test(currentToken)) {
+ // Types/Components start with uppercase
+ tokens.push({text: currentToken, color: '#4ec9b0'});
+ } else {
+ tokens.push({text: currentToken, color: '#d4d4d4'});
+ }
+ currentToken = '';
+ continue;
+ }
+
+ // Numbers
+ if (/[0-9]/.test(char)) {
+ currentToken += char;
+ i++;
+ while (i < line.length && /[0-9.]/.test(line[i])) {
+ currentToken += line[i];
+ i++;
+ }
+ tokens.push({text: currentToken, color: '#b5cea8'});
+ currentToken = '';
+ continue;
+ }
+
+ // Operators and punctuation
+ if (currentToken) {
+ tokens.push({text: currentToken, color: currentColor});
+ currentToken = '';
+ }
+
+ const operatorColor = '{}[]()'.includes(char) ? '#ffd700' : '#d4d4d4';
+ tokens.push({text: char, color: operatorColor});
+ i++;
+ }
+
+ if (currentToken) {
+ tokens.push({text: currentToken, color: currentColor});
+ }
+
+ const anchorId = isFocused && !focusAnchorAssigned ? 'story-code-focus-start' : undefined;
+ if (anchorId) focusAnchorAssigned = true;
+
+ const focusedStyle = isFocused
+ ? {
+ backgroundColor: 'rgba(0, 102, 255, 0.2)',
+ display: 'block',
+ margin: '0 -32px',
+ padding: '0 32px',
+ }
+ : undefined;
+
+ return (
+
+ {tokens.map((token, tokenIndex) => (
+
+ {token.text}
+
+ ))}
+ {'\n'}
+
+ );
+ })}
+ >
+ );
+};
+
+const StoryCodePanel = ({api}: StoryCodePanelProps): React.ReactElement => {
+ const [storyCode, setStoryCode] = React.useState(null);
+ const [loading, setLoading] = React.useState(true);
+ const containerRef = React.useRef(null);
+
+ React.useEffect(() => {
+ const channel = addons.getChannel();
+
+ const handleStoryCodeUpdate = (payload: string | StoryCodePayload) => {
+ const normalized: StoryCodePayload = typeof payload === 'string' ? {code: payload} : payload;
+ setStoryCode(normalized);
+ setLoading(false);
+ };
+
+ channel.on('story-code-update', handleStoryCodeUpdate);
+
+ // Request code when story changes
+ const handleStoryChanged = () => {
+ setLoading(true);
+ channel.emit('story-code-request');
+ };
+
+ api.on('storyChanged', handleStoryChanged);
+
+ // Initial request
+ channel.emit('story-code-request');
+
+ return () => {
+ channel.off('story-code-update', handleStoryCodeUpdate);
+ api.off('storyChanged', handleStoryChanged);
+ };
+ }, [api]);
+
+ // Scroll to the focused range when story or focus changes
+ React.useEffect(() => {
+ if (!storyCode?.focus) return;
+ const container = containerRef.current;
+ if (!container) return;
+
+ const anchor = container.querySelector('#story-code-focus-start');
+ if (anchor && anchor instanceof HTMLElement) {
+ anchor.scrollIntoView({block: 'start', behavior: 'smooth'});
+ }
+ }, [storyCode?.focus, storyCode?.code]);
+
+ return (
+
+ {loading ? (
+
Loading story code...
+ ) : storyCode ? (
+
+ {highlightCode(storyCode.code, storyCode.focus)}
+
+ ) : (
+
No code available for this story
+ )}
+
+ );
+};
+
+addons.register(ADDON_ID, (api) => {
+ addons.add(PANEL_ID, {
+ type: types.PANEL,
+ title: PANEL_TITLE,
+ render: ({active}) => (
+
+
+
+ ),
+ });
+});
diff --git a/.storybook/story-code-addon/package.json b/.storybook/story-code-addon/package.json
new file mode 100644
index 0000000000..9c9887939a
--- /dev/null
+++ b/.storybook/story-code-addon/package.json
@@ -0,0 +1,5 @@
+{
+ "name": "story-code-addon",
+ "version": "0.0.1",
+ "main": "preset.ts"
+}
diff --git a/.storybook/story-code-addon/preset.ts b/.storybook/story-code-addon/preset.ts
new file mode 100644
index 0000000000..f50da8602d
--- /dev/null
+++ b/.storybook/story-code-addon/preset.ts
@@ -0,0 +1,9 @@
+import {join} from 'path';
+
+export const managerEntries = (entry: Array = []): Array => {
+ return [...entry, join(import.meta.dirname, './manager.tsx')];
+};
+
+export const previewAnnotations = (entry: Array = []): Array => {
+ return [...entry, join(import.meta.dirname, './preview.tsx')];
+};
diff --git a/.storybook/story-code-addon/preview.tsx b/.storybook/story-code-addon/preview.tsx
new file mode 100644
index 0000000000..d440f8b7d8
--- /dev/null
+++ b/.storybook/story-code-addon/preview.tsx
@@ -0,0 +1,79 @@
+import * as React from 'react';
+import {addons} from 'storybook/preview-api';
+
+import type {Decorator} from '@storybook/react';
+
+// Extend window type to include our global variable
+declare global {
+ interface Window {
+ __STORY_SOURCE__?: Record;
+ }
+}
+
+const StoryCodeDecorator: Decorator = (Story, context) => {
+ React.useEffect(() => {
+ const channel = addons.getChannel();
+
+ const handleCodeRequest = () => {
+ const fileName = context.parameters.fileName;
+ const focusParam = context.parameters.storyCode?.focus;
+
+ const focus =
+ Array.isArray(focusParam) &&
+ focusParam.length === 2 &&
+ focusParam.every((n) => typeof n === 'number' && Number.isFinite(n) && n > 0)
+ ? ([focusParam[0], focusParam[1]] as [number, number])
+ : undefined;
+
+ // Try to get the source from our global variable
+ let storySource: string | undefined;
+
+ // eslint-disable-next-line no-underscore-dangle
+ if (typeof window !== 'undefined' && window.__STORY_SOURCE__) {
+ // Try to find the source by matching the file path
+ // eslint-disable-next-line no-underscore-dangle
+ const sources = window.__STORY_SOURCE__;
+
+ // Find a matching key in the sources object
+ const matchingKey = Object.keys(sources).find((key) => {
+ return (
+ fileName && (key.includes(fileName) || fileName.includes(key.split('/').pop() || ''))
+ );
+ });
+
+ if (matchingKey) {
+ storySource = sources[matchingKey];
+ }
+ }
+
+ // Also check if it's in parameters
+ if (!storySource && context.parameters.storySource) {
+ storySource = context.parameters.storySource;
+ }
+
+ if (storySource) {
+ channel.emit('story-code-update', {code: storySource, focus});
+ } else if (fileName) {
+ channel.emit('story-code-update', {
+ code: `// Story file: ${fileName}\n// No source code available. The source should be added to story parameters.\n\n// Available sources: ${Object.keys(window['__STORY_SOURCE__'] || {}).join(', ')}`,
+ focus,
+ });
+ } else {
+ channel.emit('story-code-update', {code: '// Story source not available', focus});
+ }
+ };
+
+ channel.on('story-code-request', handleCodeRequest);
+
+ // Send code on mount
+ handleCodeRequest();
+
+ return () => {
+ channel.off('story-code-request', handleCodeRequest);
+ };
+ }, [context.id, context.parameters]);
+
+ return ;
+};
+
+export const decorators = [StoryCodeDecorator];
diff --git a/.storybook/story-code-addon/story-source-loader.js b/.storybook/story-code-addon/story-source-loader.js
new file mode 100644
index 0000000000..32996e9081
--- /dev/null
+++ b/.storybook/story-code-addon/story-source-loader.js
@@ -0,0 +1,120 @@
+// Webpack loader to inject story source code into story parameters
+const fs = require('fs');
+const ts = require('typescript');
+
+module.exports = function (source) {
+ // Only process story files
+ if (this.resourcePath.includes('-story.tsx') || this.resourcePath.includes('.stories.tsx')) {
+ const filePath = this.resourcePath;
+
+ // Read the original source file
+ const originalSource = fs.readFileSync(filePath, 'utf-8');
+
+ // Escape backticks and template literals in the source code
+ const escapedSource = originalSource
+ .replace(/\\/g, '\\\\')
+ .replace(/`/g, '\\`')
+ .replace(/\$\{/g, '\\${');
+
+ // Collect exported story ranges using TypeScript AST (robust, no extra deps)
+ const sourceFile = ts.createSourceFile(
+ filePath,
+ originalSource,
+ ts.ScriptTarget.Latest,
+ true,
+ ts.ScriptKind.TSX
+ );
+
+ const focusMap = {};
+ const fnLookup = new Map();
+
+ // Collect potential render functions by identifier to resolve Template.bind cases
+ sourceFile.forEachChild((node) => {
+ if (ts.isFunctionDeclaration(node) && node.name) {
+ fnLookup.set(node.name.text, node);
+ }
+ if (ts.isVariableStatement(node)) {
+ node.declarationList.declarations.forEach((decl) => {
+ if (ts.isIdentifier(decl.name) && decl.initializer) {
+ fnLookup.set(decl.name.text, decl.initializer);
+ }
+ });
+ }
+ });
+
+ const isExported = (node) =>
+ Array.isArray(node.modifiers) &&
+ node.modifiers.some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
+
+ const addRange = (name, node) => {
+ if (!name) return;
+
+ // Prefer inline render functions; for bind() keep the story declaration line
+ let targetNode = node;
+
+ if (ts.isVariableDeclaration(node) && node.initializer) {
+ const init = node.initializer;
+ if (ts.isArrowFunction(init) || ts.isFunctionExpression(init)) {
+ targetNode = init;
+ }
+ }
+
+ const start = sourceFile.getLineAndCharacterOfPosition(targetNode.getStart());
+ const end = sourceFile.getLineAndCharacterOfPosition(targetNode.getEnd());
+ focusMap[name] = [start.line + 1, end.line + 1];
+ };
+
+ sourceFile.forEachChild((node) => {
+ if (ts.isFunctionDeclaration(node) && isExported(node) && node.name) {
+ addRange(node.name.text, node);
+ return;
+ }
+
+ if (ts.isVariableStatement(node) && isExported(node)) {
+ node.declarationList.declarations.forEach((decl) => {
+ if (ts.isIdentifier(decl.name)) {
+ addRange(decl.name.text, decl);
+ }
+ });
+ }
+ });
+
+ // Inject a global variable with the source code and line ranges
+ const injection = `
+// Injected by story-source-loader
+if (typeof window !== 'undefined') {
+ window.__STORY_SOURCE__ = window.__STORY_SOURCE__ || {};
+ window.__STORY_SOURCE__['${filePath}'] = \`${escapedSource}\`;
+ window.__STORY_SOURCE_LINES__ = window.__STORY_SOURCE_LINES__ || {};
+ window.__STORY_SOURCE_LINES__['${filePath}'] = ${JSON.stringify(focusMap)};
+}
+`;
+
+ // After stories are defined, attach focus ranges into their parameters automatically
+ const parameterInjection = Object.entries(focusMap)
+ .map(
+ ([exportName, range]) => `
+try {
+ if (typeof ${exportName} !== 'undefined') {
+ ${exportName}.parameters = {
+ ...(typeof ${exportName}.parameters === 'object' ? ${exportName}.parameters : {}),
+ storyCode: {
+ ...(typeof ${exportName}.parameters?.storyCode === 'object'
+ ? ${exportName}.parameters.storyCode
+ : {}),
+ focus: [${range[0]}, ${range[1]}],
+ },
+ };
+ }
+} catch (e) {
+ // Ignore errors when story export is not found
+}
+`
+ )
+ .join('\n');
+
+ return injection + '\n' + source + '\n' + parameterInjection;
+ }
+
+ return source;
+};