Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ Path aliases defined in `libs/ui/vitest.alias.ts` and `libs/ui/tsconfig.json`: `

**Build output** — Vite produces both ESM (`.js`, `preserveModules`) and CJS (`.cjs`) to `dist/libs/ui`. Post-build step (`scripts/postbuild-types.mjs`) patches `.d.ts` exports. React and `@emotion/*` are externalized. `bin/export-theme.js` serializes `defaultTheme` to JSON from the built dist; it runs automatically as part of `npm run storybook` and `npm run build-storybook`.

**Shared Storybook** — `npm run storybook` manages the React host (6006) and native Web Components preview (6007) together. Open only the host. Framework-neutral pages listed in `bin/storybook/shared-docs.json` appear at the root, before React, without a prefix. Other MDX and component stories receive a central `React/` title prefix; do not duplicate it in their titles. Keep React integration examples under React, even when their topic also has shared design guidance. The combined static artifact is `libs/ui/storybook-static`, with the child under `web-components/`. Use generated story IDs for links and run `storybook:check-links` after the combined build. Package-only validation uses `node bin/storybook/check-links.mjs --package` and indexes source stories without building previews. Do not regenerate the frozen legacy route inventory to conceal missing entries. Redirects support both original links and interim React-prefixed links to shared pages. Phase 1 contains only the native overview; component coverage and harness cleanup are later phases.
**Shared Storybook** — `npm run storybook` manages the React host (6006) and native Web Components preview (6007) together. Open only the host. Framework-neutral pages listed in `bin/storybook/shared-docs.json` appear at the root, before React, without a prefix. Other MDX and component stories receive a central `React/` title prefix; do not duplicate it in their titles. Keep React integration examples under React, even when their topic also has shared design guidance. The combined static artifact is `libs/ui/storybook-static`, with the child under `web-components/`. Use generated story IDs for links and run `storybook:check-links` after the combined build. Package-only validation uses `node bin/storybook/check-links.mjs --package` and indexes source stories without building previews. Do not regenerate the frozen legacy route inventory to conceal missing entries. Redirects support both original links and interim React-prefixed links to shared pages. The native catalog covers all 23 published atoms and two molecules. `node bin/storybook/smoke-test.mjs` verifies native rendering and representative interactions after the combined build. Keep Angular and Vue native-element harness coverage aligned with the supported catalog.

**Package subpaths** — the published package exposes four explicit subpaths (wildcards are intentionally removed to prevent internal path coupling):

Expand Down
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ Shared **Introduction**, **Theme & Tokens**, and framework-neutral **Patterns &
appear at the top level, before the framework catalogs.
One entry point contains two peer catalogs: **React** (components and React-specific guides)
and **Web Components** (native custom-element stories). The Web Components catalog currently
contains the Phase 1 overview; Phase 2 adds stories for the six existing atoms and two molecules.
contains native stories and controls for all 23 published atoms and two molecules.

`npm run storybook` manages both preview servers automatically; open `http://localhost:6006`.
Ports 6006 and 6007 must be free. There is no separate Web Components startup command.
Expand All @@ -82,7 +82,7 @@ including the composed preview under `web-components/`.

```bash
npm run storybook:check-links # Validate built Storybooks and shipped AI documentation links
npm run storybook:test # Existing React interaction tests (native coverage follows in Phase 2)
npm run storybook:test # React interaction tests
npm run storybook:visual # Existing React visual tests
```

Expand All @@ -98,7 +98,10 @@ Use the IDs in the generated `index.json` for links. Both pre-migration URLs and
list to refresh when a story disappears. Link checks cover source documentation and the
`llms.txt`, AI Markdown/JSON, and AI JavaScript copied into the UI package.

The Angular/Vue and other Web Components harnesses are unchanged in Phase 1.
Native browser smoke checks run with `node bin/storybook/smoke-test.mjs` after the combined build.
They render every native story and exercise representative custom events, state changes,
semantic output, truncation, and popover dismissal. Angular and Vue harnesses render every
supported atom family and both molecules through native custom elements.

## Verification (gd-design-library)

Expand Down
28 changes: 28 additions & 0 deletions bin/generate-icon-catalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { renderToStaticMarkup } from 'react-dom/server';
import React, { createElement } from 'react';
import { IconsList } from '../libs/ui/src/components/atoms/Icon/constants';

Object.assign(globalThis, { React });

const catalog = Object.fromEntries(
Object.entries(IconsList).map(([name, Component]) => {
const markup = renderToStaticMarkup(
createElement(Component, { fill: 'var(--gd-icon-fill)', fillSvg: 'var(--gd-icon-fill-svg)' })
);
const match = markup.match(/^<svg\b([^>]*)>([\s\S]*)<\/svg>$/);
if (!match) throw new Error(`Icon ${name} did not render one SVG root.`);
const viewBox = match[1].match(/viewBox="([^"]+)"/)?.[1];
if (!viewBox) throw new Error(`Icon ${name} has no viewBox.`);
return [name, { viewBox, body: match[2] }];
})
);

const output =
`// Generated from GridKit SVG assets by bin/generate-icon-catalog.ts.\n` +
`// Edit the source SVG and regenerate; React and Web Components consume this catalog.\n` +
`export const iconCatalog = ${JSON.stringify(catalog, null, 2)} as const;\n\n` +
`export type GridKitIconName = keyof typeof iconCatalog;\n`;

writeFileSync(resolve(import.meta.dirname, '../libs/design-core/src/icons/catalog.ts'), output);
11 changes: 11 additions & 0 deletions bin/storybook/check-links.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,17 @@ 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');
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']) {
const id = `${group}-${name}--${variant}`;
if (!indexes.webComponents.entries[id]) errors.push(`Missing native Web Components entry: ${id}`);
}
}
}

const excluded = new Set(['node_modules', '.git', 'dist', 'storybook-static', 'coverage', 'output', '.nx']);
const extensions = new Set([
Expand Down
221 changes: 221 additions & 0 deletions bin/storybook/native-checks.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import assert from 'node:assert/strict';

/** Trusted browser input is required for native popover light-dismiss. */
export async function checkNativeStories(page, base) {
const index = await (await page.request.get(`${base}/web-components/index.json`)).json();
const stories = Object.values(index.entries).filter(
(entry) =>
entry.type === 'story' && /^(Atoms|Molecules)\//.test(entry.title) && !entry.id.endsWith('--default-tokens')
);
const names = new Set();
const tags = { inputfile: 'input-file', sliderdots: 'slider-dots' };
for (const story of stories) {
const name = story.title.split('/')[1].toLowerCase();
names.add(name);
await page.goto(`${base}/web-components/iframe.html?id=${story.id}&viewMode=story`);
const tag = `gd-${tags[name] ?? name}`;
await page.locator(tag).first().waitFor({ state: 'attached' });
await page.waitForFunction((tag) => {
const el = document.querySelector(tag);
return el?.shadowRoot?.childElementCount > 0;
}, tag);
}
assert.deepEqual([...names].sort(), [
'avatar',
'badge',
'box',
'button',
'checkbox',
'counter',
'icon',
'image',
'input',
'inputfile',
'label',
'link',
'loader',
'menu',
'select',
'separator',
'skeleton',
'slider',
'sliderdots',
'switch',
'textarea',
'toggle',
'truncate',
'typography',
'wrapper',
]);
const tokenStories = Object.values(index.entries).filter(
(entry) => entry.type === 'story' && entry.id.endsWith('--default-tokens')
);
for (const story of tokenStories) {
await page.goto(`${base}/web-components/iframe.html?id=${story.id}&viewMode=story`);
const viewer = page.getByRole('region', { name: 'Token viewer' });
await viewer.waitFor();
const text = await viewer.textContent();
assert.ok(text.length > 50, `${story.id} should render its theme token values`);
assert.doesNotMatch(text, /^defaultTheme\./, `${story.id} should not render a token placeholder`);
const branch = viewer.getByRole('button').first();
await branch.click();
assert.equal(await branch.getAttribute('aria-expanded'), 'false');
}
assert.equal(tokenStories.length, 24, 'Every React token story port should render Web Component tokens');
const open = async (id) => {
await page.goto(`${base}/web-components/iframe.html?id=${id}&viewMode=story`);
await page.locator('#storybook-root').waitFor();
};
const event = async (detail) =>
page
.locator('output')
.filter({ hasText: JSON.stringify(detail) })
.waitFor();
await open('atoms-button--disabled');
await page.getByRole('button', { name: 'Button', exact: true }).waitFor();
assert.ok(await page.getByRole('button', { name: 'Button', exact: true }).isDisabled());
await open('atoms-input--default');
await page.getByRole('textbox', { name: 'Label', exact: true }).fill('Updated value');
await event({ value: 'Updated value' });
await open('atoms-input--read-only');
assert.ok(
await page
.getByRole('textbox', { name: 'Label', exact: true })
.isEditable()
.then((value) => !value)
);
await open('atoms-checkbox--default');
// The native input is visually hidden; users activate its visible label.
await page.locator('gd-checkbox label').click();
await event({ checked: true });
await page.locator('gd-checkbox label').click();
await event({ checked: false });
await open('atoms-select--default');
await page.getByRole('button', { name: 'Choose an option' }).click();
await page.getByRole('option', { name: 'Beta' }).click();
await event({ value: { name: 'Beta', value: 'b' } });
await page.getByRole('listbox').waitFor({ state: 'hidden' });
await open('atoms-select--multiple');
await page.getByRole('button', { name: 'Alpha, Gamma' }).click();
await page.getByRole('option', { name: 'Beta' }).click();
await event({
value: [
{ name: 'Alpha', value: 'a' },
{ name: 'Gamma', value: 'c' },
{ name: 'Beta', value: 'b' },
],
});
await page.getByRole('listbox').waitFor();
await open('atoms-select--searchable');
await page.getByRole('button', { name: 'Choose an option' }).click();
await page.getByRole('searchbox', { name: 'Search options' }).fill('bet');
await page.getByRole('option', { name: 'Beta' }).waitFor();
assert.equal(await page.getByRole('option', { name: 'Alpha' }).count(), 0);
await open('atoms-typography--all-variants');
assert.equal(await page.locator('gd-typography').count(), 18);
assert.deepEqual(
await page
.locator('gd-typography')
.evaluateAll((elements) =>
elements.map((element) => element.shadowRoot?.firstElementChild?.tagName.toLowerCase())
),
[
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'p',
'small',
'div',
'span',
'strong',
'i',
'code',
'kbd',
'span',
'header',
'sup',
'sub',
]
);
await open('atoms-typography--display-sizes');
const displayFontSizes = await page
.locator('gd-typography')
.evaluateAll((elements) =>
elements.map((element) => getComputedStyle(element.shadowRoot.firstElementChild).fontSize)
);
assert.equal(new Set(displayFontSizes).size, 5);
await open('atoms-slider--default');
const slider = page.getByRole('slider', { name: 'Slider' });
await slider.fill('55');
await event({ value: 55 });
await open('atoms-sliderdots--default');
await page.getByRole('tab', { name: 'Go to slide 3' }).click();
await event({ index: 2 });
await open('atoms-switch--default');
await page.locator('gd-switch label').click();
await event({ checked: true });
await open('atoms-textarea--default');
await page.getByRole('textbox').fill('Updated comment');
await event({ value: 'Updated comment' });
await open('atoms-toggle--default');
await page.getByRole('button', { name: 'Option 2', exact: true }).click();
await event({ value: 'Option 2' });
await open('atoms-truncate--line-truncation');
assert.equal(
await page
.locator('gd-truncate')
.evaluate((element) => getComputedStyle(element.shadowRoot.querySelector('[part="content"]')).webkitLineClamp),
'2'
);
await open('atoms-wrapper--custom-tag-wrapper');
assert.equal(
await page.locator('gd-wrapper').evaluate((element) => element.shadowRoot.firstElementChild.tagName),
'SECTION'
);
await open('molecules-counter--default');
await page.getByRole('button', { name: 'Increment counter', exact: true }).click();
await event({ value: 2 });
await page.getByRole('button', { name: 'Decrement counter', exact: true }).click();
await event({ value: 1 });
assert.ok(await page.getByRole('button', { name: 'Decrement counter', exact: true }).isDisabled());
await page.getByRole('spinbutton', { name: 'Quantity value' }).fill('99');
await page.getByRole('spinbutton', { name: 'Quantity value' }).press('Tab');
await event({ value: 5 });
assert.ok(await page.getByRole('button', { name: 'Increment counter', exact: true }).isDisabled());
const menuClosed = () =>
page.waitForFunction(() => {
const menu = document.querySelector('gd-menu');
const content = menu?.shadowRoot.querySelector('[popover]');
return (
menu &&
content &&
!menu.open &&
!content.matches(':popover-open') &&
getComputedStyle(content).display === 'none'
);
});
await open('molecules-menu--default');
await page.getByRole('button', { name: 'Actions', exact: true }).click();
await page.getByRole('button', { name: 'Edit', exact: true }).click();
await event({ data: { name: 'Edit', value: 'edit' }, value: 'edit' });
await menuClosed();
await page.getByRole('button', { name: 'Actions', exact: true }).click();
await page.getByRole('button', { name: 'Edit', exact: true }).waitFor();
await page.keyboard.press('Escape');
await menuClosed();
await page.getByRole('button', { name: 'Actions', exact: true }).click();
await page.getByRole('button', { name: 'Edit', exact: true }).waitFor();
await page.getByRole('button', { name: 'Outside menu' }).click();
await menuClosed();
await open('molecules-menu--keep-open-on-select');
await page.getByRole('button', { name: 'Actions', exact: true }).click();
await page.getByRole('button', { name: 'Archive', exact: true }).click();
await event({ data: { name: 'Archive', value: 'archive' }, value: 'archive' });
await page.getByRole('button', { name: 'Edit', exact: true }).waitFor();
console.log(
`Verified ${stories.length} native component stories and representative interactions for every interactive atom and molecule.`
);
}
9 changes: 8 additions & 1 deletion bin/storybook/smoke-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { readFile, stat } from 'node:fs/promises';
import { extname, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright';
import { checkNativeStories } from './native-checks.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 @@ -97,12 +98,18 @@ try {
// Composition must load the child's own native renderer, not a React wrapper.
await page.goto(`${base}/?path=/story/web-components_introduction-overview--overview`);
const nativeFrame = page.frameLocator('#storybook-ref-web-components');
await nativeFrame.getByRole('heading', { name: 'GridKit Web Components', exact: true }).waitFor();
await nativeFrame.getByRole('heading', { name: 'Using Web Components', exact: true }).waitFor();

await page.goto(`${base}/?path=/story/web-components_atoms-button--default`);
await nativeFrame.getByRole('button', { name: 'Button', exact: true }).waitFor();
await page.goto(`${base}/?path=/docs/web-components_atoms-button--docs`);
await nativeFrame.getByRole('heading', { name: 'Button', exact: true }).first().waitFor();

await page.goto(`${base}/iframe.html?id=atoms-button--default&viewMode=story&args=size:sm`);
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);
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
Loading
Loading