Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
d0c9c94
fix: restore composed Storybook controls
Zlodej43sm Oct 2, 2026
0b9663c
fix(web-components): preserve Storybook control sections
Zlodej43sm Oct 2, 2026
cbed65e
fix(web-components): match React Storybook metadata
Zlodej43sm Oct 2, 2026
ee7761f
fix(web-components): reuse migrated component primitives
Zlodej43sm Oct 2, 2026
0c0f5f6
fix(storybook): match React icon compositions
Zlodej43sm Oct 2, 2026
5374746
fix(web-components): align icon compositions with React
Zlodej43sm Oct 2, 2026
c9bacee
fix(storybook): verify painted icon and source parity
Zlodej43sm Oct 2, 2026
ac31bb1
feat(icon): document runtime custom registration
Zlodej43sm Oct 2, 2026
1ce7eae
fix(storybook): match React icon library layout
Zlodej43sm Oct 2, 2026
9377a9e
fix(image): match React stories and loading states
Zlodej43sm Oct 2, 2026
4e777e3
feat(storybook): standardize component docs parity
Zlodej43sm Oct 2, 2026
bb11c7f
test(input): update token snapshots
Zlodej43sm Oct 2, 2026
e3b7e6a
fix(select): keep dropdown anchored while scrolling
Zlodej43sm Oct 2, 2026
275fe6b
feat(storybook): centralize component descriptions and refactor Loade…
Zlodej43sm Oct 2, 2026
7682d3d
feat(storybook): add legacy route aliases for retired Loader story va…
Zlodej43sm Oct 2, 2026
ca0215b
feat(label,button): add color and ariaLabel props, reflect isLoading …
Zlodej43sm Oct 5, 2026
a60a5d4
Merge branch 'develop' into fix/CTORNDSD-679
Zlodej43sm Oct 5, 2026
fe3c2c7
feat(storybook): style catalog heading to match design system typography
Zlodej43sm Oct 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions bin/storybook/check-links.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node
import { tmpdir } from 'node:os';
import { extname, join, relative, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { legacyReactIds, sharedDocIds } from './routes.mjs';
import { legacyReactIds, legacyRouteAliases, sharedDocIds } from './routes.mjs';
import { validateLinks } from './link-validation.mjs';

const root = fileURLToPath(new URL('../../', import.meta.url));
Expand Down Expand Up @@ -49,7 +49,7 @@ if (built) {

const errors = [];
for (const id of legacyReactIds) {
const canonical = sharedDocIds.includes(id) ? id : `react-${id}`;
const canonical = legacyRouteAliases[id] ?? (sharedDocIds.includes(id) ? id : `react-${id}`);
if (!indexes.react.entries[canonical]) errors.push(`Missing migrated entry: ${canonical}`);
}
for (const id of sharedDocIds) {
Expand All @@ -67,12 +67,30 @@ if (built) {
errors.push('Shared documentation must appear before React in the built sidebar order');
}
if (!indexes.webComponents.entries['introduction-overview--overview']) errors.push('Missing Web Components overview');

// Docs is the component landing format in both catalogs: it keeps the overview,
// controls, source, and initial example together. Default remains an example
// story only where the source component defines one.
for (const [catalog, index] of Object.entries(indexes)) {
const componentEntries = Object.values(index.entries).filter((entry) => {
const title = entry.title.replace(/^React\//, '');
return /^(Atoms|Molecules|Organisms|Layout & Structure|Templates)\//.test(title);
});
const titles = [...new Set(componentEntries.map((entry) => entry.title))];
for (const title of titles) {
const entries = componentEntries.filter((entry) => entry.title === title);
const docs = entries.find((entry) => entry.type === 'docs');
if (!docs) errors.push(`${catalog}: ${title} is missing its Docs landing entry`);
if (built && docs && entries[0]?.id !== docs.id)
errors.push(`${catalog}: ${title} must open with Docs before its example stories`);
}
}
for (const [group, names] of [
['atoms', ['avatar', 'button', 'checkbox', 'input', 'select', 'typography']],
['molecules', ['counter', 'menu']],
]) {
for (const name of names) {
for (const variant of ['docs', 'default']) {
for (const variant of ['docs']) {
const id = `${group}-${name}--${variant}`;
if (!indexes.webComponents.entries[id]) errors.push(`Missing native Web Components entry: ${id}`);
}
Expand Down
20 changes: 20 additions & 0 deletions bin/storybook/check-story-parity.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { chromium } from 'playwright';
import { checkStoryInventory, checkRenderedStoryParity } from './story-parity.mjs';

console.log(`Story inventory: ${await checkStoryInventory()} paired stories.`);
if (process.argv.includes('--browser')) {
const browser = await chromium.launch({ headless: true });
try {
const report = await checkRenderedStoryParity(
browser,
process.env.REACT_STORYBOOK_URL ?? 'http://localhost:6006',
process.env.WEB_STORYBOOK_URL ?? 'http://localhost:6007',
process.env.STORY_FILTER ?? ''
);
console.log(`Compared ${report.count} rendered stories.`);
report.failures.forEach((failure) => console.error(failure));
if (report.failures.length) process.exitCode = 1;
} finally {
await browser.close();
}
}
466 changes: 397 additions & 69 deletions bin/storybook/native-checks.mjs

Large diffs are not rendered by default.

13 changes: 11 additions & 2 deletions bin/storybook/routes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,23 @@ export const legacyReactIds = JSON.parse(readFileSync(new URL('./legacy-react-id
// Explicitly reviewed framework-neutral pages; other documentation stays React-scoped.
export const sharedDocs = JSON.parse(readFileSync(new URL('./shared-docs.json', import.meta.url), 'utf8'));
export const sharedDocIds = Object.values(sharedDocs);
// Retired stories keep explicit aliases so frozen legacy URLs remain valid
// without keeping obsolete scenes in the current Storybook inventory.
export const legacyRouteAliases = {
'atoms-loader--inline-loader-button-variant': 'react-atoms-loader--default',
'atoms-loader--loader-section-variant': 'react-atoms-loader--default',
'atoms-loader--section-loader-button-variant': 'react-atoms-loader--default',
};
export const storybookOrigin = 'https://storybook.cto-rnd-system-design.griddynamics.net';

/** Resolve legacy manager, iframe and bare /docs links without dropping URL state. */
export function legacyRedirect(href, ids, sharedIds = []) {
export function legacyRedirect(href, ids, sharedIds = [], aliases = {}) {
const url = new URL(href);
const known = new Set(ids);
const shared = new Set(sharedIds);
const upgrade = (id) => {
const legacyId = id.startsWith('react-') ? id.slice(6) : id;
if (aliases[legacyId]) return aliases[legacyId];
if (shared.has(id)) return id;
if (id.startsWith('react-') && shared.has(id.slice(6))) return id.slice(6);
return known.has(id) ? `react-${id}` : id;
Expand Down Expand Up @@ -44,5 +53,5 @@ export function legacyRedirect(href, ids, sharedIds = []) {
}

export function legacyRedirectHead() {
return `<script>var migratedStorybookUrl = (${legacyRedirect.toString()})(location.href, ${JSON.stringify(legacyReactIds)}, ${JSON.stringify(sharedDocIds)}); if (migratedStorybookUrl) location.replace(migratedStorybookUrl);</script>`;
return `<script>var migratedStorybookUrl = (${legacyRedirect.toString()})(location.href, ${JSON.stringify(legacyReactIds)}, ${JSON.stringify(sharedDocIds)}, ${JSON.stringify(legacyRouteAliases)}); if (migratedStorybookUrl) location.replace(migratedStorybookUrl);</script>`;
}
19 changes: 16 additions & 3 deletions bin/storybook/routes.test.mjs
Original file line number Diff line number Diff line change
@@ -1,21 +1,34 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { legacyReactIds, sharedDocIds, legacyRedirect, legacyRedirectHead } from './routes.mjs';
import { legacyReactIds, legacyRouteAliases, sharedDocIds, legacyRedirect, legacyRedirectHead } from './routes.mjs';

const origin = 'https://storybook.example';

test('every legacy story and docs route resolves to its canonical root', () => {
for (const id of legacyReactIds) {
const kind = id.endsWith('--docs') ? 'docs' : 'story';
const href = `${origin}/?path=/${kind}/${id}&args=size:sm&globals=theme:dark#example`;
const result = new URL(legacyRedirect(href, legacyReactIds, sharedDocIds) || href);
assert.equal(result.searchParams.get('path'), `/${kind}/${sharedDocIds.includes(id) ? id : `react-${id}`}`);
const result = new URL(legacyRedirect(href, legacyReactIds, sharedDocIds, legacyRouteAliases) || href);
const canonical = legacyRouteAliases[id] ?? (sharedDocIds.includes(id) ? id : `react-${id}`);
assert.equal(result.searchParams.get('path'), `/${kind}/${canonical}`);
assert.equal(result.searchParams.get('args'), 'size:sm');
assert.equal(result.searchParams.get('globals'), 'theme:dark');
assert.equal(result.hash, '#example');
}
});

test('retired Loader scenes redirect to the current default scene', () => {
for (const id of Object.keys(legacyRouteAliases)) {
for (const route of [`/?path=/story/${id}`, `/?path=/story/react-${id}`, `/iframe.html?id=${id}`]) {
const result = new URL(legacyRedirect(origin + route, legacyReactIds, sharedDocIds, legacyRouteAliases));
assert.equal(
(result.searchParams.get('path') || result.searchParams.get('id')).replace(/^\/story\//, ''),
'react-atoms-loader--default'
);
}
}
});

test('iframe links retain their view mode and args', () => {
const result = new URL(
legacyRedirect(`${origin}/iframe.html?id=atoms-button--docs&viewMode=docs&args=size:sm`, legacyReactIds)
Expand Down
8 changes: 7 additions & 1 deletion bin/storybook/smoke-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { extname, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright';
import { checkNativeStories } from './native-checks.mjs';
import { checkRenderedStoryParity } from './story-parity.mjs';

// Serve the exact combined artifact. --url also checks the common dev command.
const root = fileURLToPath(new URL('../../libs/ui/storybook-static/', import.meta.url));
Expand Down Expand Up @@ -117,7 +118,12 @@ try {
await page.waitForURL((url) => url.searchParams.get('id') === 'react-atoms-button--default');
await page.getByRole('button').first().waitFor();
assert.equal(new URL(page.url()).searchParams.get('args'), 'size:sm');
if (!process.argv.includes('--url')) await checkNativeStories(page, base);
if (!process.argv.includes('--url')) {
await checkNativeStories(page, base);
const parity = await checkRenderedStoryParity(browser, base, `${base}/web-components`);
assert.deepEqual(parity.failures, [], 'Every paired story must preserve its React example');
console.log(`Compared ${parity.count} rendered React/Web Components stories.`);
}
assert.deepEqual(errors, [], 'Storybook should not raise browser runtime errors');
console.log(
'Storybook smoke checks passed: React docs, AI docs, internal links, composition, and legacy manager/iframe routes.'
Expand Down
219 changes: 219 additions & 0 deletions bin/storybook/story-parity.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import assert from 'node:assert/strict';
import { readFile, readdir } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import ts from 'typescript';

const root = fileURLToPath(new URL('../../', import.meta.url));
function exportsOf(file, text) {
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
return source.statements
.filter(
(node) => ts.isVariableStatement(node) && node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)
)
.flatMap((node) => node.declarationList.declarations.map((d) => d.name.getText(source)));
}

/** Source ASTs, rather than a hand-maintained list, define the paired inventory. */
export async function checkStoryInventory() {
const sourceFiles = await readdir(`${root}libs/ui/src/components`, { recursive: true });
const contracts = JSON.parse(await readFile(`${root}libs/web-components/react-parity.json`, 'utf8'));
const targetFiles = await readdir(`${root}libs/web-components/stories`);
for (const contract of Object.values(contracts)) {
assert.ok(targetFiles.includes(`${contract.react}.stories.ts`), `${contract.react}: missing native story file`);
}
let count = 0;
for (const targetFile of targetFiles) {
if (!targetFile.endsWith('.stories.ts') || targetFile === 'Overview.stories.ts') continue;
const component = targetFile.replace('.stories.ts', '');
const sourceFile = sourceFiles.find((file) => file.endsWith(`/${component}.stories.tsx`));
assert.ok(sourceFile, `No React counterpart for ${targetFile}`);
const [source, target] = await Promise.all([
readFile(`${root}libs/ui/src/components/${sourceFile}`, 'utf8'),
readFile(`${root}libs/web-components/stories/${targetFile}`, 'utf8'),
]);
const excluded = Object.values(contracts).find((contract) => contract.react === component)?.excludedStories ?? [];
const expected = exportsOf(sourceFile, source)
.filter((name) => !excluded.includes(name))
.sort();
assert.deepEqual(
exportsOf(targetFile, target).sort(),
expected,
`${component}: stories must match React one for one (no extra aliases)`
);
count += expected.length;
}
return count;
}

// Walk the composed tree: slot content must be compared at its painted position,
// and hidden popovers/fallback slots must not count as visible example content.
export function canvasInventory() {
const texts = [];
const inputs = [];
const walk = (node) => {
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent.replace(/\s+/g, ' ').trim();
if (text && node.parentElement && getComputedStyle(node.parentElement).visibility !== 'hidden') {
const range = document.createRange();
range.selectNodeContents(node);
if ([...range.getClientRects()].some((r) => r.width && r.height)) texts.push(text);
}
return;
}
if (node instanceof HTMLSlotElement) {
const assigned = node.assignedNodes({ flatten: true });
(assigned.length ? assigned : [...node.childNodes]).forEach(walk);
return;
}
if (node instanceof Element) {
if (
['SCRIPT', 'STYLE'].includes(node.tagName) ||
(getComputedStyle(node).display !== 'contents' && !node.checkVisibility({ checkVisibilityCSS: true }))
)
return;
if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement)
inputs.push({
type: node.type,
value: ['checkbox', 'radio'].includes(node.type) ? undefined : node.value,
checked: node instanceof HTMLInputElement ? node.checked : undefined,
disabled: node.disabled,
});
if (node.shadowRoot) {
[...node.shadowRoot.childNodes].forEach(walk);
return;
}
}
[...node.childNodes].forEach(walk);
};
walk(document.querySelector('#storybook-root'));
return { text: texts.join(' ').replace(/\s+/g, ' ').trim(), inputs };
}

export async function checkRenderedStoryParity(browser, reactBase, webBase, filter = '') {
const reactIndex = await (await fetch(`${reactBase}/index.json`)).json();
const webIndex = await (await fetch(`${webBase}/index.json`)).json();
const pages = await Promise.all([
browser.newPage({ viewport: { width: 1280, height: 900 } }),
browser.newPage({ viewport: { width: 1280, height: 900 } }),
]);
for (const page of pages) {
page.setDefaultTimeout(15000);
await page.route('https://picsum.photos/**', (route) =>
route.fulfill({
contentType: 'image/svg+xml',
body: '<svg xmlns="http://www.w3.org/2000/svg" width="300" height="300"><rect width="300" height="300" fill="#91b8d8"/></svg>',
})
);
}
const failures = [];
let count = 0;
try {
for (const target of Object.values(webIndex.entries).filter(
(entry) =>
entry.type === 'story' &&
/^(Atoms|Molecules)\//.test(entry.title) &&
!entry.id.endsWith('--default-tokens') &&
(!filter || entryMatches(entry, filter))
)) {
const source = Object.values(reactIndex.entries).find(
(entry) =>
entry.type === 'story' &&
entry.title.replace(/^React\//, '') === target.title &&
entry.id.split('--')[1] === target.id.split('--')[1]
);
if (!source) {
failures.push(`${target.id}: no React counterpart`);
continue;
}
try {
const results = await Promise.allSettled(
pages.map(async (page, i) => {
const entry = i ? target : source;
await page.goto(`${i ? webBase : reactBase}/iframe.html?id=${entry.id}&viewMode=story&embed=true`, {
waitUntil: 'domcontentloaded',
});
await page.waitForFunction(
() =>
document.querySelector('#storybook-root')?.childNodes.length ||
document.querySelector('body.sb-show-errordisplay')
);
await page.evaluate(async () => {
await document.fonts.ready;
for (let i = 0; i < 4; i++) await new Promise(requestAnimationFrame);
});
const error = (await page.locator('.sb-errordisplay').isVisible())
? await page.locator('.sb-errordisplay').textContent()
: '';
if (error) throw new Error(error);
return page.evaluate(canvasInventory);
})
);
const failed = results.find((result) => result.status === 'rejected');
if (failed) throw failed.reason;
const inventories = results.map((result) => result.value);
assert.deepEqual(inventories[1], inventories[0]);
assert.equal(target.name, source.name, 'Story display name differs');
// Text equality cannot catch a checkbox rendered with text-field chrome.
// Measure the painted controls, not the deliberately hidden Checkbox input.
if (
/^atoms-input--(checkbox-with-label|radio.*with-label|primary-default-with-label-and-helper-text)$/.test(
target.id
) ||
/^atoms-checkbox--(default|disabled|sizes|indeterminate)$/.test(target.id)
) {
const isCheckbox = target.id.startsWith('atoms-checkbox--');
const geometry = await Promise.all(
pages.map((page, index) =>
page
.locator(
isCheckbox
? index
? 'gd-checkbox [part="indicator"]'
: '[data-testid="Checkbox-indicator"]'
: 'input'
)
.evaluateAll((nodes) =>
nodes.map((node) => {
const rect = node.getBoundingClientRect();
const style = getComputedStyle(node);
return {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
appearance: style.appearance,
background: style.backgroundColor,
image: style.backgroundImage,
border: style.border,
};
})
)
)
);
assert.equal(geometry[1].length, geometry[0].length, 'Painted control count');
geometry[0].forEach((expected, i) => {
const actual = geometry[1][i];
for (const field of ['x', 'y', 'width', 'height'])
assert.ok(
Math.abs(actual[field] - expected[field]) <= 0.6,
`Control ${i} ${field}: ${actual[field]} differs from React ${expected[field]}`
);
for (const field of ['appearance', 'background', 'image', 'border'])
assert.equal(actual[field], expected[field], `Control ${i} ${field}`);
});
}
} catch (error) {
failures.push(`${target.id}: ${error.message}`);
console.log(`Difference: ${target.id}`);
}
count++;
if (count % 25 === 0) console.log(`Compared ${count} stories (${failures.length} differences so far).`);
}
} finally {
await Promise.all(pages.map((page) => page.close()));
}
return { count, failures };
}
function entryMatches(entry, filter) {
return entry.title.toLowerCase().includes(filter.toLowerCase());
}
Loading
Loading