diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 6aa6f765a..3148e82f8 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -407,6 +407,47 @@ jobs: path: packages/buckaroo-js-core/screenshots/ if-no-files-found: ignore + StylingScreenshots: + name: Styling Screenshots + runs-on: depot-ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.10.0 + - name: Setup Node.js with pnpm cache + uses: actions/setup-node@v6 + with: + cache: 'pnpm' + cache-dependency-path: 'packages/pnpm-lock.yaml' + - name: Install pnpm dependencies + working-directory: packages + run: pnpm install --no-frozen-lockfile + - name: Cache Playwright browsers + uses: actions/cache@v5 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ hashFiles('packages/buckaroo-js-core/package.json') }} + - name: Install Playwright browsers + working-directory: packages/buckaroo-js-core + run: pnpm exec playwright install chromium + - name: Capture screenshots + working-directory: packages/buckaroo-js-core + env: + SCREENSHOT_DIR: screenshots/styling + run: pnpm exec playwright test pw-tests/styling-issues-screenshots.spec.ts --reporter=line + - name: Upload screenshots + if: always() + uses: actions/upload-artifact@v4 + with: + name: styling-screenshots + path: packages/buckaroo-js-core/screenshots/styling/ + if-no-files-found: ignore + TestServer: name: Server Playwright Tests needs: [BuildWheel] diff --git a/packages/buckaroo-js-core/pw-tests/styling-issues-screenshots.spec.ts b/packages/buckaroo-js-core/pw-tests/styling-issues-screenshots.spec.ts new file mode 100644 index 000000000..efc21dad4 --- /dev/null +++ b/packages/buckaroo-js-core/pw-tests/styling-issues-screenshots.spec.ts @@ -0,0 +1,107 @@ +/** + * Playwright screenshot capture for styling-issue stories. + * Follows the theme-screenshots.spec.ts pattern (light-only). + * + * SCREENSHOT_DIR env var controls output directory (default: screenshots/after). + * Run once on each commit to produce "before" and "after" sets: + * + * SCREENSHOT_DIR=screenshots/before npx playwright test pw-tests/styling-issues-screenshots.spec.ts + * SCREENSHOT_DIR=screenshots/after npx playwright test pw-tests/styling-issues-screenshots.spec.ts + */ +import { test } from '@playwright/test'; +import path from 'path'; +import fs from 'fs'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const STORYBOOK_BASE = 'http://localhost:6006/iframe.html?viewMode=story&id='; + +// Output directory — overridable via env var +const screenshotsDir = path.resolve( + __dirname, + '..', + process.env.SCREENSHOT_DIR ?? 'screenshots/after', +); + +/** + * All 16 styling-issue stories. + * Story IDs follow Storybook's slug rules: + * title "Buckaroo/DFViewer/StylingIssues" → "buckaroo-dfviewer-stylingissues" + * export name e.g. FewCols_ShortHdr_ShortData → "few-cols-short-hdr-short-data" + * (Storybook runs startCase() on the export name before slugifying) + */ +const STORIES = [ + // Section A – width/contention (#595, #596, #599, #600) + { id: 'buckaroo-dfviewer-stylingissues--few-cols-short-hdr-short-data', name: 'A1_FewCols_ShortHdr_ShortData', issues: '#599' }, + { id: 'buckaroo-dfviewer-stylingissues--few-cols-short-hdr-long-data', name: 'A2_FewCols_ShortHdr_LongData', issues: '' }, + { id: 'buckaroo-dfviewer-stylingissues--few-cols-long-hdr-short-data', name: 'A3_FewCols_LongHdr_ShortData', issues: '' }, + { id: 'buckaroo-dfviewer-stylingissues--few-cols-long-hdr-long-data', name: 'A4_FewCols_LongHdr_LongData', issues: '' }, + { id: 'buckaroo-dfviewer-stylingissues--many-cols-short-hdr-short-data', name: 'A5_ManyCols_ShortHdr_ShortData', issues: '#595 #599' }, + { id: 'buckaroo-dfviewer-stylingissues--many-cols-short-hdr-long-data', name: 'A6_ManyCols_ShortHdr_LongData', issues: '#596' }, + { id: 'buckaroo-dfviewer-stylingissues--many-cols-long-hdr-short-data', name: 'A7_ManyCols_LongHdr_ShortData', issues: '#596' }, + { id: 'buckaroo-dfviewer-stylingissues--many-cols-long-hdr-long-data', name: 'A8_ManyCols_LongHdr_LongData', issues: '#596 worst-case' }, + { id: 'buckaroo-dfviewer-stylingissues--many-cols-long-hdr-year-data', name: 'A9_ManyCols_LongHdr_YearData', issues: '#595 primary' }, + + // Section B – large numbers / compact_number (#597, #602) + // Note: compact_number stories may render raw values on pre-#597 commits. + { id: 'buckaroo-dfviewer-stylingissues--large-numbers-float', name: 'B9_LargeNumbers_Float', issues: '#597 before' }, + { id: 'buckaroo-dfviewer-stylingissues--large-numbers-compact', name: 'B10_LargeNumbers_Compact', issues: '#597 after' }, + { id: 'buckaroo-dfviewer-stylingissues--clustered-billions-float', name: 'B11_ClusteredBillions_Float', issues: '#602 baseline' }, + { id: 'buckaroo-dfviewer-stylingissues--clustered-billions-compact', name: 'B12_ClusteredBillions_Compact', issues: '#602 precision' }, + + // Section C – pinned row / index alignment (#587) + { id: 'buckaroo-dfviewer-stylingissues--pinned-index-few-cols', name: 'C13_PinnedIndex_FewCols', issues: '#587' }, + { id: 'buckaroo-dfviewer-stylingissues--pinned-index-many-cols', name: 'C14_PinnedIndex_ManyCols', issues: '#587' }, + + // Section D – mixed cross-issue scenarios + { id: 'buckaroo-dfviewer-stylingissues--mixed-many-narrow-with-pinned', name: 'D15_Mixed_ManyNarrow_WithPinned', issues: '#595 #587 #599' }, + { id: 'buckaroo-dfviewer-stylingissues--mixed-few-wide-with-pinned', name: 'D16_Mixed_FewWide_WithPinned', issues: '#587 baseline' }, +]; + +test.beforeAll(() => { + fs.mkdirSync(screenshotsDir, { recursive: true }); +}); + +for (const story of STORIES) { + test(`screenshot ${story.name}`, async ({ page }) => { + await page.emulateMedia({ colorScheme: 'light' }); + await page.goto(`${STORYBOOK_BASE}${story.id}`); + + // Wait for AG-Grid cells or any visible content + const cell = page.locator('.ag-cell'); + const cellWrapper = page.locator('.ag-cell-wrapper'); + const noRows = page.locator('.ag-overlay-no-rows-center'); + const fullWidth = page.locator('.ag-full-width-row'); + const sbContent = page.locator('#storybook-root'); + + await cell + .or(cellWrapper) + .or(noRows) + .or(fullWidth) + .or(sbContent) + .first() + .waitFor({ state: 'visible', timeout: 15000 }); + + // Settle time for animations / lazy column-width calculation + await page.waitForTimeout(800); + + // For pinned-index stories, scroll the grid body right so the + // index column is out of view — exposes #587 alignment bug. + // Use keyboard End key to scroll to the rightmost column. + if (story.name.includes('Pinned')) { + // Click a cell first to give the grid focus + const firstCell = page.locator('.ag-cell').first(); + await firstCell.click(); + await page.waitForTimeout(200); + // Press End to scroll to the last column + await page.keyboard.press('End'); + await page.waitForTimeout(400); + } + + await page.screenshot({ + path: path.join(screenshotsDir, `${story.name}.png`), + fullPage: true, + }); + }); +} diff --git a/packages/buckaroo-js-core/src/components/DFViewerParts/DFViewerInfinite.tsx b/packages/buckaroo-js-core/src/components/DFViewerParts/DFViewerInfinite.tsx index bc44eb748..150e912c2 100644 --- a/packages/buckaroo-js-core/src/components/DFViewerParts/DFViewerInfinite.tsx +++ b/packages/buckaroo-js-core/src/components/DFViewerParts/DFViewerInfinite.tsx @@ -303,7 +303,7 @@ export function DFViewerInfiniteInner({ return { ...outerGridOptions(setActiveCol, df_viewer_config.extra_grid_config), domLayout: hs.domLayout, - autoSizeStrategy: getAutoSize(styledColumns.length), + autoSizeStrategy: df_viewer_config.extra_grid_config?.autoSizeStrategy || getAutoSize(styledColumns.length), onFirstDataRendered: (_params) => { // Grid finished rendering }, diff --git a/packages/buckaroo-js-core/src/stories/StylingIssues.stories.tsx b/packages/buckaroo-js-core/src/stories/StylingIssues.stories.tsx new file mode 100644 index 000000000..f6fc5dd71 --- /dev/null +++ b/packages/buckaroo-js-core/src/stories/StylingIssues.stories.tsx @@ -0,0 +1,446 @@ +/** + * Stories to reproduce styling / column-width issues: + * #587 – pinned-row index alignment + * #595 / #596 / #599 / #600 – column width contention (few vs many, short vs long headers/data) + * #597 – compact_number displayer for large values + * #602 – compact_number precision loss on clustered billion-scale values + * + * Full 2×2×2 combinatorial matrix for width stories, plus large-number and pinned-row scenarios. + */ +import type { Meta, StoryObj } from "@storybook/react"; +import { DFViewerInfinite } from "../components/DFViewerParts/DFViewerInfinite"; +import { ShadowDomWrapper } from "./StoryUtils"; +import { + DFViewerConfig, + NormalColumnConfig, + PinnedRowConfig, +} from "../components/DFViewerParts/DFWhole"; + +type DFRow = Record; + +// ── Column header name pools ──────────────────────────────────────────────── + +const SHORT_HEADER_NAMES = [ + "a","b","c","d","e","f","g","h","i","j","k","l","m", + "n","o","p","q","r","s","t","u","v","w","x","y", +]; + +const LONG_HEADER_NAMES = [ + "revenue_total","customer_count","margin_pct_adj","transaction_vol", + "active_users_n","retention_rate","avg_order_val","monthly_recur_r", + "churn_pct_adj","lifetime_value","gross_margin_rt","net_promoter_sc", + "conv_rate_pct","acq_cost_avg","rev_per_user_q","visits_monthly", + "bounce_rate_pc","avg_session_s","page_views_tot","email_open_rt", + "click_thru_pct","refund_rate_pc","support_tkts","upsell_rev_q", + "referral_cnt", +]; + +const INDEX_COL: NormalColumnConfig = { + col_name: "index", + header_name: "index", + displayer_args: { displayer: "obj" }, +}; + +// ── Data generators ───────────────────────────────────────────────────────── + +const ROW_COUNT = 20; + +type DataStyle = "short" | "long" | "large" | "clustered" | "year"; +type HeaderStyle = "short" | "long"; + +function makeShortVal(row: number, col: number): number { + return ((row * 7 + col * 3 + 1) % 99) + 1; // 1–99 +} + +function makeLongVal(row: number, col: number): number { + return 100_000 + ((row * 1_234_567 + col * 234_567) % 9_900_000); // 100k–9.9M +} + +function makeLargeVal(row: number, col: number): number { + // 3M – 5.7B spread + return 3_000_000 + ((row * 987_654_321 + col * 123_456_789) % 5_697_000_000); +} + +function makeClusteredVal(row: number, col: number): number { + // 5.60B – 5.68B (tight cluster to expose compact_number precision loss) + return 5_600_000_000 + ((row * 12_345 + col * 5_678) % 80_000_000); +} + +function makeYearVal(row: number, col: number): number { + // 4-digit year-like values — narrow content that triggers #595 + return 2000 + ((row * 3 + col * 7) % 25); +} + +function genData(count: number, dataStyle: DataStyle): DFRow[] { + const valFn = + dataStyle === "short" ? makeShortVal : + dataStyle === "long" ? makeLongVal : + dataStyle === "large" ? makeLargeVal : + dataStyle === "year" ? makeYearVal : + makeClusteredVal; + + return Array.from({ length: ROW_COUNT }, (_, row) => { + const r: DFRow = { index: row }; + for (let col = 0; col < count; col++) { + r[`col_${col}`] = valFn(row, col); + } + return r; + }); +} + +function genSummary(count: number, dataStyle: DataStyle): DFRow[] { + const colKeys = Array.from({ length: count }, (_, i) => `col_${i}`); + const isFloat = dataStyle === "large" || dataStyle === "clustered"; + const dtype = isFloat ? "float64" : "int64"; + + const row = (key: string, valFn: (i: number) => number | string): DFRow => { + const r: DFRow = { index: key }; + colKeys.forEach((k, i) => { r[k] = valFn(i); }); + return r; + }; + + if (dataStyle === "year") { + return [ + row("dtype", () => dtype), + row("non_null_count",() => ROW_COUNT), + row("mean", () => 2012), + row("std", () => 7), + row("min", () => 2000), + row("max", () => 2024), + ]; + } else if (dataStyle === "short") { + return [ + row("dtype", () => dtype), + row("non_null_count",() => ROW_COUNT), + row("mean", (i) => 40 + i * 3), + row("std", (i) => 20 + i), + row("min", (i) => 1 + i), + row("max", (i) => 90 + i), + ]; + } else if (dataStyle === "long") { + return [ + row("dtype", () => dtype), + row("non_null_count",() => ROW_COUNT), + row("mean", (i) => 5_000_000 + i * 100_000), + row("std", (i) => 2_000_000 + i * 50_000), + row("min", (i) => 100_000 + i * 10_000), + row("max", (i) => 9_800_000 + i * 10_000), + ]; + } else if (dataStyle === "large") { + return [ + row("dtype", () => dtype), + row("non_null_count",() => ROW_COUNT), + row("mean", (i) => 2_000_000_000 + i * 100_000_000), + row("std", () => 1_000_000_000), + row("min", (i) => 3_000_000 + i * 1_000_000), + row("max", () => 5_700_000_000), + ]; + } else { + return [ + row("dtype", () => dtype), + row("non_null_count",() => ROW_COUNT), + row("mean", () => 5_640_000_000), + row("std", () => 20_000_000), + row("min", () => 5_600_000_000), + row("max", () => 5_680_000_000), + ]; + } +} + +// ── Config builders ───────────────────────────────────────────────────────── + +function genConfig( + count: number, + headerStyle: HeaderStyle, + dataStyle: DataStyle, + withPinned = false, +): DFViewerConfig { + const headers = + headerStyle === "short" ? SHORT_HEADER_NAMES : LONG_HEADER_NAMES; + + const displayer_args = (): NormalColumnConfig["displayer_args"] => { + if (dataStyle === "large" || dataStyle === "clustered") { + return { displayer: "float", min_fraction_digits: 2, max_fraction_digits: 2 }; + } + return { + displayer: "integer", + min_digits: 1, + max_digits: dataStyle === "year" ? 4 : dataStyle === "short" ? 2 : 7, + }; + }; + + const column_config: NormalColumnConfig[] = Array.from({ length: count }, (_, i) => ({ + col_name: `col_${i}`, + header_name: headers[i % headers.length], + displayer_args: displayer_args(), + ...(withPinned ? { ag_grid_specs: { minWidth: 120 } } : {}), + })); + + const pinned_rows: PinnedRowConfig[] = withPinned + ? [ + { primary_key_val: "dtype", displayer_args: { displayer: "obj" } }, + { primary_key_val: "non_null_count", displayer_args: { displayer: "inherit" } }, + { primary_key_val: "mean", displayer_args: { displayer: "inherit" } }, + { primary_key_val: "std", displayer_args: { displayer: "inherit" } }, + { primary_key_val: "min", displayer_args: { displayer: "inherit" } }, + { primary_key_val: "max", displayer_args: { displayer: "inherit" } }, + ] + : []; + + return { column_config, left_col_configs: [INDEX_COL], pinned_rows }; +} + +// ── Story factory ─────────────────────────────────────────────────────────── + +function makeStoryComponent( + config: DFViewerConfig, + data: DFRow[], + summary: DFRow[] = [], + width = 800, +) { + const data_wrapper = { data_type: "Raw" as const, data, length: data.length }; + return function StoryInner() { + return ( + +
+ {}} + outside_df_params={{}} + /> +
+
+ ); + }; +} + +// ── Meta ──────────────────────────────────────────────────────────────────── + +// Placeholder component; all stories use render() below +const _Placeholder = () => null; + +const meta = { + title: "Buckaroo/DFViewer/StylingIssues", + component: _Placeholder, + parameters: { layout: "centered" }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// ── Section A: Width / contention (#595, #596, #599, #600) ───────────────── +// Full 2×2×2 = 8 combinations of: col count × header length × data length + +const FewShortShortInner = makeStoryComponent( + genConfig(5, "short", "short"), + genData(5, "short"), +); +/** Baseline – 5 cols, 1-char headers, 1-2 digit values. Should look fine. (#599) */ +export const FewCols_ShortHdr_ShortData: Story = { + render: () => , +}; + +const FewShortLongInner = makeStoryComponent( + genConfig(5, "short", "long"), + genData(5, "long"), +); +/** 5 cols, 1-char headers, 6-7 digit values. Data drives width, no contention. */ +export const FewCols_ShortHdr_LongData: Story = { + render: () => , +}; + +const FewLongShortInner = makeStoryComponent( + genConfig(5, "long", "short"), + genData(5, "short"), +); +/** 5 cols, 12-18 char headers, 1-2 digit values. Header wider than data. */ +export const FewCols_LongHdr_ShortData: Story = { + render: () => , +}; + +const FewLongLongInner = makeStoryComponent( + genConfig(5, "long", "long"), + genData(5, "long"), +); +/** 5 cols, long headers, long data. Both are wide; no contention at 5 cols. */ +export const FewCols_LongHdr_LongData: Story = { + render: () => , +}; + +const ManyShortShortInner = makeStoryComponent( + genConfig(25, "short", "short"), + genData(25, "short"), +); +/** 25 cols, 1-char headers, 1-2 digit values. Primary bug case (#595/#599). */ +export const ManyCols_ShortHdr_ShortData: Story = { + render: () => , +}; + +const ManyShortLongInner = makeStoryComponent( + genConfig(25, "short", "long"), + genData(25, "long"), +); +/** 25 cols, 1-char headers, 6-7 digit values. Data wants space (#596). */ +export const ManyCols_ShortHdr_LongData: Story = { + render: () => , +}; + +const ManyLongShortInner = makeStoryComponent( + genConfig(25, "long", "short"), + genData(25, "short"), +); +/** 25 cols, 12-18 char headers, 1-2 digit values. Headers want space (#596). */ +export const ManyCols_LongHdr_ShortData: Story = { + render: () => , +}; + +const ManyLongLongInner = makeStoryComponent( + genConfig(25, "long", "long"), + genData(25, "long"), +); +/** 25 cols, long headers, long data. Worst-case contention (#596). */ +export const ManyCols_LongHdr_LongData: Story = { + render: () => , +}; + +// #595 repro: fitGridWidth with 25 cols in 800px → ~32px each → "..." +// Without defaultColDef.minWidth (baseline) → columns crushed → "..." +// With defaultColDef.minWidth: 80 (fix) → columns at 80px → scrollbar + readable values +const narrowColConfig: DFViewerConfig = { + column_config: Array.from({ length: 25 }, (_, i) => ({ + col_name: `col_${i}`, + header_name: LONG_HEADER_NAMES[i], + displayer_args: { displayer: "integer" as const, min_digits: 1, max_digits: 4 }, + })), + left_col_configs: [INDEX_COL], + pinned_rows: [], + extra_grid_config: { autoSizeStrategy: { type: "fitGridWidth" as const } }, +}; +const NarrowColInner = makeStoryComponent( + narrowColConfig, + genData(25, "year"), +); +/** 25 cols with fitGridWidth in 800px. #595 repro — values show "..." without minWidth fix. */ +export const ManyCols_LongHdr_YearData: Story = { + render: () => , +}; + +// ── Section B: Large numbers / compact (#597, #602) ───────────────────────── + +const largeFloatConfig: DFViewerConfig = { + column_config: Array.from({ length: 5 }, (_, i) => ({ + col_name: `col_${i}`, + header_name: SHORT_HEADER_NAMES[i], + displayer_args: { displayer: "float" as const, min_fraction_digits: 2, max_fraction_digits: 2 }, + })), + left_col_configs: [INDEX_COL], + pinned_rows: [], +}; +const largeData = genData(5, "large"); + +const LargeNumbersFloatInner = makeStoryComponent(largeFloatConfig, largeData); +/** 5 cols, values 3M–5.7B, float displayer. Shows why compact is needed (#597). */ +export const LargeNumbers_Float: Story = { + render: () => , +}; + +const largeCompactConfig: DFViewerConfig = { + column_config: Array.from({ length: 5 }, (_, i) => ({ + col_name: `col_${i}`, + header_name: SHORT_HEADER_NAMES[i], + displayer_args: { displayer: "compact_number" as const }, + })), + left_col_configs: [INDEX_COL], + pinned_rows: [], +}; + +const LargeNumbersCompactInner = makeStoryComponent(largeCompactConfig, largeData); +/** Same data as LargeNumbers_Float but using compact_number displayer (#597 fix). */ +export const LargeNumbers_Compact: Story = { + render: () => , +}; + +const clusteredData = genData(5, "clustered"); + +const clusteredFloatConfig: DFViewerConfig = { + column_config: Array.from({ length: 5 }, (_, i) => ({ + col_name: `col_${i}`, + header_name: SHORT_HEADER_NAMES[i], + displayer_args: { displayer: "float" as const, min_fraction_digits: 2, max_fraction_digits: 2 }, + })), + left_col_configs: [INDEX_COL], + pinned_rows: [], +}; + +const ClusteredBillionsFloatInner = makeStoryComponent(clusteredFloatConfig, clusteredData); +/** 5 cols, values tightly clustered 5.60B–5.68B, float displayer (#602 baseline). */ +export const ClusteredBillions_Float: Story = { + render: () => , +}; + +const clusteredCompactConfig: DFViewerConfig = { + column_config: Array.from({ length: 5 }, (_, i) => ({ + col_name: `col_${i}`, + header_name: SHORT_HEADER_NAMES[i], + displayer_args: { displayer: "compact_number" as const }, + })), + left_col_configs: [INDEX_COL], + pinned_rows: [], +}; + +const ClusteredBillionsCompactInner = makeStoryComponent(clusteredCompactConfig, clusteredData); +/** Same clustered data with compact_number – exposes precision loss (#602). */ +export const ClusteredBillions_Compact: Story = { + render: () => , +}; + +// ── Section C: Pinned row / index (#587) ──────────────────────────────────── + +const pinnedFewCfg = genConfig(10, "long", "short", true); +const pinnedFewData = genData(10, "short"); +const pinnedFewSummary = genSummary(10, "short"); + +const PinnedIndexFewInner = makeStoryComponent(pinnedFewCfg, pinnedFewData, pinnedFewSummary, 400); +/** 10 long-header cols + pinned summary stats + left index. Tests #587 alignment. */ +export const PinnedIndex_FewCols: Story = { + render: () => , +}; + +const pinnedManyCfg = genConfig(20, "long", "short", true); +const pinnedManyData = genData(20, "short"); +const pinnedManySummary = genSummary(20, "short"); + +const PinnedIndexManyInner = makeStoryComponent(pinnedManyCfg, pinnedManyData, pinnedManySummary, 400); +/** 20 long-header cols + pinned summary stats. #587 alignment under width contention. */ +export const PinnedIndex_ManyCols: Story = { + render: () => , +}; + +// ── Section D: Mixed scenarios ─────────────────────────────────────────────── + +const mixedManyNarrowCfg = genConfig(20, "long", "short", true); +const mixedManyNarrowData = genData(20, "short"); +const mixedManyNarrowSummary = genSummary(20, "short"); + +const MixedManyNarrowInner = makeStoryComponent( + mixedManyNarrowCfg, mixedManyNarrowData, mixedManyNarrowSummary, 400, +); +/** 20 narrow cols + pinned rows. Cross-issue: #595 + #587 + #599. */ +export const Mixed_ManyNarrow_WithPinned: Story = { + render: () => , +}; + +const mixedFewWideCfg = genConfig(5, "long", "long", true); +const mixedFewWideData = genData(5, "long"); +const mixedFewWideSummary = genSummary(5, "long"); + +const MixedFewWideInner = makeStoryComponent( + mixedFewWideCfg, mixedFewWideData, mixedFewWideSummary, 400, +); +/** 5 wide cols + pinned rows. #587 baseline (should look fine). */ +export const Mixed_FewWide_WithPinned: Story = { + render: () => , +}; diff --git a/scripts/download_styling_screenshots.sh b/scripts/download_styling_screenshots.sh new file mode 100755 index 000000000..b481ed847 --- /dev/null +++ b/scripts/download_styling_screenshots.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Download before/after styling screenshots from a CI run. +# +# Usage: +# ./scripts/download_styling_screenshots.sh [run-id] +# +# If run-id is omitted, uses the latest successful run of checks.yml +# on the current branch. + +set -e + +REPO=$(gh repo view --json nameWithOwner -q '.nameWithOwner') +BRANCH=$(git rev-parse --abbrev-ref HEAD) +RUN_ID=${1:-} + +if [ -z "$RUN_ID" ]; then + echo "Looking up latest successful run on branch '$BRANCH'..." + RUN_ID=$(gh run list \ + --repo "$REPO" \ + --branch "$BRANCH" \ + --workflow checks.yml \ + --status success \ + --limit 1 \ + --json databaseId \ + -q '.[0].databaseId') + + if [ -z "$RUN_ID" ]; then + echo "No successful run found for branch '$BRANCH'. Try passing a run-id directly." + exit 1 + fi +fi + +echo "Downloading screenshots from run $RUN_ID (repo: $REPO)" + +OUT_DIR="packages/buckaroo-js-core/screenshots" +mkdir -p "$OUT_DIR" + +gh run download "$RUN_ID" \ + --repo "$REPO" \ + --name styling-screenshots-before \ + --dir "$OUT_DIR/before" + +gh run download "$RUN_ID" \ + --repo "$REPO" \ + --name styling-screenshots-after \ + --dir "$OUT_DIR/after" + +BEFORE=$(ls -1 "$OUT_DIR/before"/*.png 2>/dev/null | wc -l | tr -d ' ') +AFTER=$(ls -1 "$OUT_DIR/after"/*.png 2>/dev/null | wc -l | tr -d ' ') +echo "Downloaded: $BEFORE before screenshots, $AFTER after screenshots" +echo "" +echo "Next step:" +echo " python scripts/gen_screenshot_compare.py && open packages/buckaroo-js-core/screenshots/compare.html" diff --git a/scripts/gen_screenshot_compare.py b/scripts/gen_screenshot_compare.py new file mode 100644 index 000000000..164dd9e0d --- /dev/null +++ b/scripts/gen_screenshot_compare.py @@ -0,0 +1,566 @@ +#!/usr/bin/env python3 +""" +Generate a self-contained before/after screenshot comparison HTML page. + +Usage: + python scripts/gen_screenshot_compare.py + +Reads screenshots from: + packages/buckaroo-js-core/screenshots/before/*.png + packages/buckaroo-js-core/screenshots/after/*.png + +Writes: + packages/buckaroo-js-core/screenshots/compare.html +""" +import base64 +import json +import sys +from pathlib import Path + +SCRIPT_DIR = Path(__file__).parent +REPO_ROOT = SCRIPT_DIR.parent +SCREENSHOTS_DIR = REPO_ROOT / "packages" / "buckaroo-js-core" / "screenshots" +BEFORE_DIR = SCREENSHOTS_DIR / "before" +AFTER_DIR = SCREENSHOTS_DIR / "after" +OUTPUT = SCREENSHOTS_DIR / "compare.html" + +# Stories in display order, with section headings and issue labels +STORIES = [ + # Section A + ("A – Width / Contention (#595, #596, #599, #600)", [ + ("A1_FewCols_ShortHdr_ShortData", "#599 baseline"), + ("A2_FewCols_ShortHdr_LongData", "few cols, long data"), + ("A3_FewCols_LongHdr_ShortData", "few cols, long headers"), + ("A4_FewCols_LongHdr_LongData", "few cols, both wide"), + ("A5_ManyCols_ShortHdr_ShortData", "#595 #599 primary bug"), + ("A6_ManyCols_ShortHdr_LongData", "#596 data contention"), + ("A7_ManyCols_LongHdr_ShortData", "#596 header contention"), + ("A8_ManyCols_LongHdr_LongData", "#596 worst case"), + ]), + # Section B + ("B – Large Numbers / compact_number (#597, #602)", [ + ("B9_LargeNumbers_Float", "#597 – float displayer (before)"), + ("B10_LargeNumbers_Compact", "#597 – compact_number (after)"), + ("B11_ClusteredBillions_Float", "#602 – clustered, float"), + ("B12_ClusteredBillions_Compact", "#602 – clustered, compact (precision loss)"), + ]), + # Section C + ("C – Pinned Row / Index Alignment (#587)", [ + ("C13_PinnedIndex_FewCols", "#587 – 5 cols"), + ("C14_PinnedIndex_ManyCols", "#587 – 15 cols"), + ]), + # Section D + ("D – Mixed Scenarios", [ + ("D15_Mixed_ManyNarrow_WithPinned", "#595 #587 #599"), + ("D16_Mixed_FewWide_WithPinned", "#587 baseline"), + ]), +] + + +def img_data_uri(path: Path) -> str: + if not path.exists(): + return "" + b64 = base64.b64encode(path.read_bytes()).decode() + return f"data:image/png;base64,{b64}" + + +def build_html() -> str: + # Build flat story list with embedded images for JS consumption + flat: list[dict] = [] + for section_title, stories in STORIES: + for name, label in stories: + flat.append({ + "name": name, + "label": label, + "section": section_title, + "before": img_data_uri(BEFORE_DIR / f"{name}.png"), + "after": img_data_uri(AFTER_DIR / f"{name}.png"), + }) + + stories_json = json.dumps(flat) + + # Build nav items HTML (section headers + story entries) + nav_items = [] + current_section = None + for i, entry in enumerate(flat): + if entry["section"] != current_section: + current_section = entry["section"] + nav_items.append( + f'' + ) + short = entry["name"].split("_", 1)[1].replace("_", " ") if "_" in entry["name"] else entry["name"] + nav_items.append( + f'' + ) + nav_html = "\n".join(nav_items) + + return f""" + + + +Styling Issues: Before / After + + + +
+ + + + + +
+
+ + +
+ +
+
+ before + before + +
+
+ after + after + +
+
+
+ +
+ + + +""" + + +if __name__ == "__main__": + if not BEFORE_DIR.exists() and not AFTER_DIR.exists(): + print( + "No screenshots found.\n" + "Run:\n" + " ./scripts/download_styling_screenshots.sh\n" + "or capture locally with:\n" + " cd packages/buckaroo-js-core && " + "SCREENSHOT_DIR=screenshots/after npx playwright test pw-tests/styling-issues-screenshots.spec.ts", + file=sys.stderr, + ) + sys.exit(1) + + html = build_html() + OUTPUT.write_text(html, encoding="utf-8") + print(f"Written: {OUTPUT}") + + before_count = len(list(BEFORE_DIR.glob("*.png"))) if BEFORE_DIR.exists() else 0 + after_count = len(list(AFTER_DIR.glob("*.png"))) if AFTER_DIR.exists() else 0 + print(f" before: {before_count} screenshots") + print(f" after: {after_count} screenshots")