diff --git a/public/images/app-dark-1280.webp b/public/images/app-dark-1280.webp index 843772f..24d6600 100644 Binary files a/public/images/app-dark-1280.webp and b/public/images/app-dark-1280.webp differ diff --git a/public/images/app-dark-1920.webp b/public/images/app-dark-1920.webp index 3de8cd3..e7ded6e 100644 Binary files a/public/images/app-dark-1920.webp and b/public/images/app-dark-1920.webp differ diff --git a/public/images/app-dark.png b/public/images/app-dark.png index 3dcce5b..d3e3b86 100644 Binary files a/public/images/app-dark.png and b/public/images/app-dark.png differ diff --git a/public/images/app-dark.webp b/public/images/app-dark.webp index 2480e2f..4263da0 100644 Binary files a/public/images/app-dark.webp and b/public/images/app-dark.webp differ diff --git a/public/images/app-light-1280.webp b/public/images/app-light-1280.webp index dd8187d..5684b5d 100644 Binary files a/public/images/app-light-1280.webp and b/public/images/app-light-1280.webp differ diff --git a/public/images/app-light-1920.webp b/public/images/app-light-1920.webp index 48bebea..06e796c 100644 Binary files a/public/images/app-light-1920.webp and b/public/images/app-light-1920.webp differ diff --git a/public/images/app-light.png b/public/images/app-light.png index b4b2358..f43616b 100644 Binary files a/public/images/app-light.png and b/public/images/app-light.png differ diff --git a/public/images/app-light.webp b/public/images/app-light.webp index cf5ef0a..ea0c91b 100644 Binary files a/public/images/app-light.webp and b/public/images/app-light.webp differ diff --git a/public/images/features/data-grid-dark-1216.webp b/public/images/features/data-grid-dark-1216.webp index e1296ea..78d9848 100644 Binary files a/public/images/features/data-grid-dark-1216.webp and b/public/images/features/data-grid-dark-1216.webp differ diff --git a/public/images/features/data-grid-dark-2432.webp b/public/images/features/data-grid-dark-2432.webp index c799279..d719014 100644 Binary files a/public/images/features/data-grid-dark-2432.webp and b/public/images/features/data-grid-dark-2432.webp differ diff --git a/public/images/features/data-grid-dark.png b/public/images/features/data-grid-dark.png index a11b77e..c58d574 100644 Binary files a/public/images/features/data-grid-dark.png and b/public/images/features/data-grid-dark.png differ diff --git a/public/images/features/data-grid-light-1216.webp b/public/images/features/data-grid-light-1216.webp index 99bd5fa..f0fb60a 100644 Binary files a/public/images/features/data-grid-light-1216.webp and b/public/images/features/data-grid-light-1216.webp differ diff --git a/public/images/features/data-grid-light-2432.webp b/public/images/features/data-grid-light-2432.webp index 0ceb6b1..036231a 100644 Binary files a/public/images/features/data-grid-light-2432.webp and b/public/images/features/data-grid-light-2432.webp differ diff --git a/public/images/features/data-grid-light.png b/public/images/features/data-grid-light.png index 19af97d..7bfca9e 100644 Binary files a/public/images/features/data-grid-light.png and b/public/images/features/data-grid-light.png differ diff --git a/public/images/features/sql-editor-dark-1216.webp b/public/images/features/sql-editor-dark-1216.webp index bfb3605..5f7b291 100644 Binary files a/public/images/features/sql-editor-dark-1216.webp and b/public/images/features/sql-editor-dark-1216.webp differ diff --git a/public/images/features/sql-editor-dark-2432.webp b/public/images/features/sql-editor-dark-2432.webp index 830442a..017e2c2 100644 Binary files a/public/images/features/sql-editor-dark-2432.webp and b/public/images/features/sql-editor-dark-2432.webp differ diff --git a/public/images/features/sql-editor-dark.png b/public/images/features/sql-editor-dark.png index 87fb031..cdb5bd9 100644 Binary files a/public/images/features/sql-editor-dark.png and b/public/images/features/sql-editor-dark.png differ diff --git a/public/images/features/sql-editor-light-1216.webp b/public/images/features/sql-editor-light-1216.webp index 3321aa6..9c784bc 100644 Binary files a/public/images/features/sql-editor-light-1216.webp and b/public/images/features/sql-editor-light-1216.webp differ diff --git a/public/images/features/sql-editor-light-2432.webp b/public/images/features/sql-editor-light-2432.webp index 44b3ba9..140ecec 100644 Binary files a/public/images/features/sql-editor-light-2432.webp and b/public/images/features/sql-editor-light-2432.webp differ diff --git a/public/images/features/sql-editor-light.png b/public/images/features/sql-editor-light.png index edaa7ef..0971420 100644 Binary files a/public/images/features/sql-editor-light.png and b/public/images/features/sql-editor-light.png differ diff --git a/resources/js/components/landing/hero.tsx b/resources/js/components/landing/hero.tsx index 6fb240a..34e7c11 100644 --- a/resources/js/components/landing/hero.tsx +++ b/resources/js/components/landing/hero.tsx @@ -149,7 +149,7 @@ export default function Hero({ githubStars, latestRelease }: Props) { '/images/app-dark-1280.webp 1280w, /images/app-dark-1920.webp 1920w, /images/app-dark.webp 3024w', }} sizes="(max-width: 1280px) 1280px, (max-width: 1920px) 1920px, 3024px" - alt="TablePro on macOS: a SQL query and its result grid, with the connection sidebar on the left." + alt="TablePro on macOS: the orders table open in the data grid, sixty rows deep, with the connection sidebar on the left." width={3024} height={1720} priority diff --git a/resources/js/components/landing/workbench.tsx b/resources/js/components/landing/workbench.tsx index 4a51930..dfaa053 100644 --- a/resources/js/components/landing/workbench.tsx +++ b/resources/js/components/landing/workbench.tsx @@ -11,6 +11,18 @@ interface Shot { /** Base name under /images/features. The 1216/2432 WebP ladder is derived from it. */ name: string; alt: string; + /** + * Which edge of the screenshot carries its subject. + * + * The cell blows every shot up to 145% and clips it, so only 1/1.45 of the + * image — 69% — is ever on the page. Anchoring used to follow row parity, + * which is a fact about the layout and not about the picture: it put the + * AI assistant's panel, which lives on the right of that shot, in the 31% + * that gets cut. The row was captioned "AI Assistant" above a screenshot + * with no assistant in it. Parity still decides which side the cell sits on + * and where its hairline goes; the picture decides this. + */ + anchor: 'left' | 'right'; } /** @@ -60,6 +72,7 @@ const ROWS: Row[] = [ shot: { name: 'sql-editor', alt: 'The SQL editor with a multi-statement query and its result tabs below.', + anchor: 'left', }, rows: [ { @@ -91,7 +104,8 @@ const ROWS: Row[] = [ body: 'Every column type gets its own editor: a calendar for timestamps, a searchable list of referenced rows for foreign keys, a three-state checkbox for nullable booleans. Nothing reaches the database until you press Save.', shot: { name: 'data-grid', - alt: 'The data grid with edited cells highlighted and a pending-changes count in the toolbar.', + alt: 'The data grid with an edited cell highlighted and the save control live, the change still only in memory.', + anchor: 'right', }, rows: [ { @@ -113,7 +127,8 @@ const ROWS: Row[] = [ body: 'Explain a query, optimize it, or fix one that failed. Suggestions arrive as a before-and-after diff with numbered steps tagged Critical, Change or Context.', shot: { name: 'ai-assistant', - alt: 'The AI assistant showing a before and after diff of a rewritten query with numbered explanation steps.', + alt: 'The AI assistant answering a question in a side panel, with the SQL it generated and a step-by-step explanation.', + anchor: 'right', }, rows: [ { @@ -189,9 +204,21 @@ export default function Workbench() { return (
+ {/* + * The column template flips with the row, not just the + * order. `order-2` moves the copy cell past the shot in + * placement order but leaves the track sizes alone, so a + * fixed `[minmax(0,24rem)_1fr]` handed every even row's + * screenshot the 24rem track and its prose the 1fr one. + * Row 02 rendered a 2432px shot into 384px of cell — the + * width `shotSources` sizes the whole ladder against is + * 1216 — and nothing in that window was legible. + */}
*:first-child]:order-2' : '' + className={`lg:grid lg:items-stretch ${ + isEven + ? 'lg:grid-cols-[1fr_minmax(0,24rem)] lg:[&>*:first-child]:order-2' + : 'lg:grid-cols-[minmax(0,24rem)_1fr]' }`} >
@@ -225,7 +252,7 @@ export default function Workbench() { width={2432} height={1385} sizes="(min-width: 1024px) 1216px, 100vw" - className={`lg:w-[145%] lg:max-w-none ${isEven ? 'lg:-translate-x-[31%]' : ''}`} + className={`lg:w-[145%] lg:max-w-none ${row.shot.anchor === 'right' ? 'lg:-translate-x-[31%]' : ''}`} />
diff --git a/tests/Feature/Landing/ImageAssetsTest.php b/tests/Feature/Landing/ImageAssetsTest.php new file mode 100644 index 0000000..5be67e0 --- /dev/null +++ b/tests/Feature/Landing/ImageAssetsTest.php @@ -0,0 +1,191 @@ + + */ +function frontendSourceFiles(): array +{ + $files = []; + $tree = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator(resource_path('js'), FilesystemIterator::SKIP_DOTS), + ); + + foreach ($tree as $file) { + if (in_array($file->getExtension(), ['ts', 'tsx'], true)) { + $files[] = $file->getPathname(); + } + } + + return $files; +} + +/** + * @return list + */ +function publicImageFiles(): array +{ + $files = []; + $tree = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator(public_path('images'), FilesystemIterator::SKIP_DOTS), + ); + + foreach ($tree as $file) { + if (in_array(strtolower($file->getExtension()), ['png', 'webp', 'jpg', 'jpeg'], true)) { + $files[] = $file->getPathname(); + } + } + + sort($files); + + return $files; +} + +/** + * Literal `/images/...` paths only. A path built from a template literal has no + * extension at the point of reference and is skipped here; the two assertions + * that walk `public/images` cover those files instead. + * + * @return array Map of web path to the source file naming it. + */ +function referencedImagePaths(): array +{ + $paths = []; + + foreach (frontendSourceFiles() as $file) { + preg_match_all('#/images/[A-Za-z0-9@._/-]+\.(?:png|webp|jpe?g|svg|avif)#', (string) file_get_contents($file), $matches); + + foreach ($matches[0] as $path) { + $paths[$path] ??= str_replace(base_path() . '/', '', $file); + } + } + + ksort($paths); + + return $paths; +} + +/** + * @return array{0: int, 1: int} + */ +function imageDimensions(string $absolutePath): array +{ + $size = @getimagesize($absolutePath); + + if ($size === false) { + return [0, 0]; + } + + return [$size[0], $size[1]]; +} + +it('serves every image the frontend links to', function (): void { + $missing = []; + $referenced = referencedImagePaths(); + + expect($referenced)->not->toBeEmpty(); + + foreach ($referenced as $path => $source) { + if (! is_file(public_path(ltrim($path, '/')))) { + $missing[] = "{$path} referenced by {$source}"; + } + } + + expect($missing)->toBe([]); +}); + +it('gives every srcSet candidate the width its descriptor claims', function (): void { + $wrong = []; + $checked = 0; + + foreach (frontendSourceFiles() as $file) { + preg_match_all('#(/images/[A-Za-z0-9@._/-]+\.webp)\s+(\d+)w#', (string) file_get_contents($file), $matches, PREG_SET_ORDER); + + foreach ($matches as $match) { + [, $path, $declared] = $match; + $absolute = public_path(ltrim($path, '/')); + + if (! is_file($absolute)) { + continue; + } + + [$actual] = imageDimensions($absolute); + $checked++; + + if ($actual !== (int) $declared) { + $wrong[] = "{$path} declares {$declared}w but is {$actual}px wide"; + } + } + } + + expect($wrong)->toBe([]); + expect($checked)->toBeGreaterThan(0); +}); + +/** + * The ladder is generated from the base shot, so the width lives in the + * filename. This is the assertion that reaches the derivatives named by a + * template literal, which the `srcSet` scan above cannot see. + */ +it('gives every ladder derivative the width in its filename', function (): void { + $wrong = []; + $checked = 0; + + foreach (publicImageFiles() as $absolute) { + if (preg_match('#-(\d+)\.webp$#', $absolute, $match) !== 1) { + continue; + } + + [$actual] = imageDimensions($absolute); + $checked++; + + if ($actual !== (int) $match[1]) { + $name = basename($absolute); + $wrong[] = "{$name} is {$actual}px wide"; + } + } + + expect($wrong)->toBe([]); + expect($checked)->toBeGreaterThan(0); +}); + +it('shoots both themes of a pair at one size', function (): void { + $mismatched = []; + $checked = 0; + + foreach (publicImageFiles() as $absolute) { + if (! str_contains($absolute, '-light')) { + continue; + } + + $dark = str_replace('-light', '-dark', $absolute); + + if (! is_file($dark)) { + continue; + } + + $lightSize = imageDimensions($absolute); + $darkSize = imageDimensions($dark); + $checked++; + + if ($lightSize !== $darkSize) { + $name = basename($absolute); + $mismatched[] = "{$name} is {$lightSize[0]}x{$lightSize[1]} but its dark variant is {$darkSize[0]}x{$darkSize[1]}"; + } + } + + expect($mismatched)->toBe([]); + expect($checked)->toBeGreaterThan(0); +});