diff --git a/public/site.webmanifest b/public/site.webmanifest index 90b5dd6..9ac339c 100644 --- a/public/site.webmanifest +++ b/public/site.webmanifest @@ -5,7 +5,7 @@ "start_url": "/", "display": "browser", "background_color": "#ffffff", - "theme_color": "#000000", + "theme_color": "#ffffff", "icons": [ { "src": "/logo.png", diff --git a/resources/css/app.css b/resources/css/app.css index b2d5524..7f7758a 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -159,6 +159,7 @@ --ring: oklch(0.72 0.178 55); --rule: oklch(0.145 0 0 / 0.07); --rule-strong: oklch(0.145 0 0 / 0.14); + --surface-raised: oklch(0.975 0 0); } .dark { @@ -185,6 +186,65 @@ --ring: oklch(0.72 0.178 55); --rule: oklch(1 0 0 / 0.08); --rule-strong: oklch(1 0 0 / 0.16); + --surface-raised: oklch(0.185 0 0); +} + +/* + * The product screenshot. + * + * `shadow-sm` is a 3px blur. On an image rendered 1216px wide that is an order + * of magnitude too small, which is why the hero read as a sticker laid on the + * page rather than a window sitting above it. Every reference Mac-app site + * converged on the same shape instead: a big soft ambient drop, and no single + * blur anywhere. + * + * `drop-shadow` rather than `box-shadow` because the captures carry real + * transparent macOS squircle corners — verified, the shipped PNGs have an alpha + * channel — so the shadow follows the actual silhouette. A box-shadow would + * draw a rectangle behind a rounded image, and CSS `border-radius` is a + * circular arc that cannot reproduce a continuous corner anyway. That is also + * why the border and radius come off: the image already has the corners, and a + * 1px rule tracing a squircle with an arc is visibly wrong at the join. + * + * Warm-tinted rather than neutral black, so it sits in the page's own light + * instead of muddying it. Three stacked is the practical paint limit. + */ +.app-plate { + filter: + drop-shadow(0 1px 1px oklch(0.145 0.004 55 / 0.04)) + drop-shadow(0 6px 8px oklch(0.145 0.004 55 / 0.05)) + drop-shadow(0 24px 32px oklch(0.145 0.004 55 / 0.07)); +} + +.dark .app-plate { + filter: + drop-shadow(0 1px 1px oklch(0 0 0 / 0.3)) + drop-shadow(0 8px 16px oklch(0 0 0 / 0.35)) + drop-shadow(0 32px 48px oklch(0 0 0 / 0.3)); +} + +/* + * The second ground. + * + * Sixteen sections shared one background, so over roughly ten thousand pixels + * of scroll the only rhythm device was hairline density — a reader could not + * tell from peripheral vision whether they had moved from Architecture to + * Agents to Mobile. `--card`, `--secondary` and `--accent` were all declared + * and used zero times. + * + * Two grounds, not five. The discipline is the point: `raised` goes only to the + * sections that are already conceptually inset. + * + * This has to sit after `.dark`, because `[data-tone="raised"]` and `.dark` are + * both specificity (0,1,0) and source order breaks the tie. It only needs one + * rule because `--surface-raised` is itself theme-swapped above. + * + * Every foreground token was checked against this surface rather than assumed: + * the tightest is `--muted-foreground-subtle` at 4.61:1, which still clears AA. + */ +[data-tone="raised"] { + --background: var(--surface-raised); + background-color: var(--background); } @layer base { diff --git a/resources/js/components/landing/database-grid.tsx b/resources/js/components/landing/database-grid.tsx index a48ebfd..4de2831 100644 --- a/resources/js/components/landing/database-grid.tsx +++ b/resources/js/components/landing/database-grid.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useMemo, useRef, useState } from 'react'; import { Plus } from 'lucide-react'; import gridData from '../../../data/database-grid.json'; import Container from '@/components/ui/container'; @@ -78,6 +78,7 @@ function TileBody({ database }: { database: DatabaseTile }) { export default function DatabaseGrid() { const [activeCategory, setActiveCategory] = useState('all'); + const tileRefs = useRef<(HTMLAnchorElement | null)[]>([]); const counts = useMemo(() => { const tally: Record = { all: databases.length }; @@ -98,6 +99,67 @@ export default function DatabaseGrid() { [activeCategory], ); + /** + * Columns at each breakpoint, so up and down move a whole row rather than a + * single tile. Read off `COLS` so the two can never disagree. + */ + function columnsNow(): number { + if (typeof window === 'undefined') { + return COLS.lg ?? COLS.base; + } + + const width = window.innerWidth; + if (width >= 1024) return COLS.lg ?? COLS.base; + if (width >= 768) return COLS.md ?? COLS.base; + if (width >= 640) return COLS.sm ?? COLS.base; + + return COLS.base; + } + + function handleGridKeyDown(event: React.KeyboardEvent) { + // `visible` tiles plus the "request a database" tile at the end. + const last = visible.length; + const columns = columnsNow(); + const current = tileRefs.current.findIndex((el) => el === document.activeElement); + + if (current === -1) { + return; + } + + const moves: Record = { + ArrowRight: 1, + ArrowLeft: -1, + ArrowDown: columns, + ArrowUp: -columns, + }; + + let next: number | null = null; + + if (event.key in moves) { + next = current + moves[event.key]; + } else if (event.key === 'Home') { + next = 0; + } else if (event.key === 'End') { + next = last; + } + + // Clamp rather than wrap: wrapping a two-dimensional grid on Left at the + // start of a row lands you at the end of the previous one, which reads + // as the focus jumping backwards for no reason. + if (next === null) { + return; + } + + const clamped = Math.max(0, Math.min(last, next)); + + if (clamped === current) { + return; + } + + event.preventDefault(); + tileRefs.current[clamped]?.focus(); + } + const visibleIndex = useMemo(() => { const map = new Map(); visible.forEach((database, i) => map.set(database.name, i)); @@ -160,7 +222,25 @@ export default function DatabaseGrid() {
-
+ {/* + * Arrow-key traversal across the tiles. Up and down move + * a whole row, Home and End jump to the ends. + * + * Purely additive: every tile stays in the tab order. + * The usual composite-widget pattern would take them all + * out and leave one, but that needs `role="grid"` with + * `row` and `gridcell` children for assistive tech to + * understand what happened — and this is one flat CSS + * grid whose borders are computed by index, so row + * wrappers would break the layout. Adding the roles + * without the structure would describe a widget that is + * not there. Arrows are a shortcut for people who can + * see where focus went; nobody loses anything. + */} +
{databases.map((database) => { const index = visibleIndex.get(database.name); @@ -181,6 +261,7 @@ export default function DatabaseGrid() { return ( { tileRefs.current[index] = el; }} href={database.href} data-row className={`${TILE_CLASS} ${TILE_HOVER} ${borders}`} @@ -191,6 +272,7 @@ export default function DatabaseGrid() { })} { tileRefs.current[visible.length] = el; }} href={REQUEST_DATABASE_HREF} data-row target="_blank" diff --git a/resources/js/components/landing/depth-grid.tsx b/resources/js/components/landing/depth-grid.tsx index 68c028c..7296b12 100644 --- a/resources/js/components/landing/depth-grid.tsx +++ b/resources/js/components/landing/depth-grid.tsx @@ -65,7 +65,8 @@ const ITEMS: DepthItem[] = [ */ export default function DepthGrid() { return ( - +
diff --git a/resources/js/components/landing/faq.tsx b/resources/js/components/landing/faq.tsx index 9c770dd..ce2fb3c 100644 --- a/resources/js/components/landing/faq.tsx +++ b/resources/js/components/landing/faq.tsx @@ -34,7 +34,8 @@ export default function FAQ({ const mid = Math.ceil(items.length / 2); return ( - +
diff --git a/resources/js/components/landing/hero.tsx b/resources/js/components/landing/hero.tsx index 45c9b65..987b33a 100644 --- a/resources/js/components/landing/hero.tsx +++ b/resources/js/components/landing/hero.tsx @@ -129,7 +129,7 @@ export default function Hero({ githubStars, latestRelease }: Props) { width={3024} height={1720} priority - className="w-full rounded-xl border border-rule shadow-sm" + className="app-plate w-full" />
diff --git a/resources/js/components/landing/pricing.tsx b/resources/js/components/landing/pricing.tsx index 891b212..ada452e 100644 --- a/resources/js/components/landing/pricing.tsx +++ b/resources/js/components/landing/pricing.tsx @@ -1,9 +1,10 @@ import { useState, useEffect } from 'react'; import { toast } from 'sonner'; import Container from '@/components/ui/container'; -import { AccentLine, FullLine } from '@/components/ui/full-line'; +import DataTable from '@/components/ui/data-table'; +import { FullLine } from '@/components/ui/full-line'; import { Ledger, LedgerRow } from '@/components/ui/ledger'; -import SectionLabel from '@/components/ui/section-label'; +import SectionShell from '@/components/ui/section-shell'; type BillingCycle = 'monthly' | 'yearly' | 'lifetime'; @@ -328,40 +329,14 @@ export default function Pricing({ paymentProvider, teamMinSeats }: { paymentProv ]; return ( -
-
- - {/* Label */} - - - - Pricing - - - - - {/* Spacer */} -
- - {/* Headline */} - - -

- The app is free. -
- The license funds it. -

- -
-
- - -

- Starter is per person. Team is per seat, from {teamMinSeats} seats. Yearly saves 33 percent. - Lifetime pays for itself against yearly in about two and a half years. -

- -
+ {/* What a license actually buys. Stated before the prices, on purpose. */}
@@ -555,22 +530,22 @@ export default function Pricing({ paymentProvider, teamMinSeats }: { paymentProv * * Availability is now real text, hidden visually. */} - - + - - - @@ -587,7 +562,7 @@ export default function Pricing({ paymentProvider, teamMinSeats }: { paymentProv return ( ))} -
- What each plan includes, compared across Free, Starter and Team. -
Feature + Free + Starter + Team
@@ -614,10 +589,10 @@ export default function Pricing({ paymentProvider, teamMinSeats }: { paymentProv
+
-
+
); } diff --git a/resources/js/components/landing/safety.tsx b/resources/js/components/landing/safety.tsx index af24b8c..c3b25c4 100644 --- a/resources/js/components/landing/safety.tsx +++ b/resources/js/components/landing/safety.tsx @@ -1,5 +1,6 @@ import { useRef, useState } from 'react'; import Container from '@/components/ui/container'; +import DataTable, { TABLE_ROW_RULE } from '@/components/ui/data-table'; import { FullLine } from '@/components/ui/full-line'; import { cellBorders, GridCell, type ColumnMap } from '@/components/ui/grid-cell'; import SectionShell from '@/components/ui/section-shell'; @@ -129,6 +130,7 @@ export default function Safety() { return ( - - + {COLUMNS.map((column) => ( @@ -196,7 +197,7 @@ export default function Safety() { ))} -
Safe Mode levels and their behaviour
+
diff --git a/resources/js/components/landing/spec-strip.tsx b/resources/js/components/landing/spec-strip.tsx index 754d0ec..9b48bb6 100644 --- a/resources/js/components/landing/spec-strip.tsx +++ b/resources/js/components/landing/spec-strip.tsx @@ -1,5 +1,6 @@ import { ReactNode } from 'react'; import Container from '@/components/ui/container'; +import DataTable, { TABLE_COLUMN_RULE, TABLE_ROW_RULE } from '@/components/ui/data-table'; import { FullLine } from '@/components/ui/full-line'; interface Props { @@ -82,11 +83,10 @@ export default function SpecStrip({ latestRelease }: Props) {
- - + {/* * Column separators take the strong weight and the single @@ -99,7 +99,7 @@ export default function SpecStrip({ latestRelease }: Props) { - + {specs.map((spec) => ( ))} -
- TablePro in numbers: database count, cold start, idle memory, download size, licence and - latest release. -
{spec.label} @@ -113,11 +113,11 @@ export default function SpecStrip({ latestRelease }: Props) {
@@ -157,7 +157,7 @@ export default function SpecStrip({ latestRelease }: Props) {
+
diff --git a/resources/js/components/ui/data-table.tsx b/resources/js/components/ui/data-table.tsx new file mode 100644 index 0000000..7c0d0f9 --- /dev/null +++ b/resources/js/components/ui/data-table.tsx @@ -0,0 +1,47 @@ +import { ReactNode } from 'react'; +import { cn } from '@/lib/utils'; + +/** + * Column separators. Columns are stable structure, so they take the heavier + * rule — the same distinction the hairline system makes everywhere else. + */ +export const TABLE_COLUMN_RULE = 'border-rule-strong'; + +/** Row separators. Rows are data, so they take the hairline. */ +export const TABLE_ROW_RULE = 'border-rule'; + +interface DataTableProps { + /** + * Required, and visually hidden. + * + * Three tables on this site carry real tabular data, and a table without a + * caption gives a screen reader no way to know what it is looking at before + * it starts reading cells. Making it a required prop is the only reliable + * way to keep that true. + */ + caption: string; + className?: string; + children: ReactNode; +} + +/** + * The shared shell for the site's data tables. + * + * Deliberately small. The three tables it serves — the spec result set, the + * Safe Mode ladder and the plan comparison — have genuinely different + * typography and density, and a primitive that tried to parameterise all of + * that would be harder to read than the markup it replaced. + * + * What it does own is the part that had already drifted: the plan comparison + * drew its column separators with the row weight, so the one table where + * columns carry the entire meaning was the one drawing them faintest. Exporting + * the two weights by name is what stops that happening again. + */ +export default function DataTable({ caption, className, children }: DataTableProps) { + return ( + + + {children} +
{caption}
+ ); +} diff --git a/resources/js/components/ui/section-shell.tsx b/resources/js/components/ui/section-shell.tsx index e31aaf2..1fb2cf5 100644 --- a/resources/js/components/ui/section-shell.tsx +++ b/resources/js/components/ui/section-shell.tsx @@ -23,6 +23,14 @@ interface SectionShellProps { * the argument and which were the appendix. */ tier?: 'argument' | 'reference'; + /** + * `raised` puts the section on the second ground. + * + * Reserved for sections that are already conceptually inset. Two grounds, + * not five — a page that changes background every section has no rhythm + * either, it just has noise. + */ + tone?: 'base' | 'raised'; className?: string; children: ReactNode; } @@ -51,13 +59,19 @@ export default function SectionShell({ headlineMuted, lede, tier = 'argument', + tone = 'base', className, children, }: SectionShellProps) { const headingId = `${id}-heading`; return ( -
+
diff --git a/resources/js/pages/Compare.tsx b/resources/js/pages/Compare.tsx index 07544e1..174cdcd 100644 --- a/resources/js/pages/Compare.tsx +++ b/resources/js/pages/Compare.tsx @@ -394,7 +394,7 @@ export default function Compare({ slug, downloadUrls, githubStars }: Props) { alt="TablePro interface showing the data grid and SQL editor" width={3024} height={1720} - className="w-full rounded-xl border border-rule shadow-sm" + className="app-plate w-full" loading="lazy" /> @@ -408,7 +408,7 @@ export default function Compare({ slug, downloadUrls, githubStars }: Props) { alt="TablePro interface showing the data grid and SQL editor" width={3024} height={1720} - className="w-full rounded-xl border border-rule shadow-sm" + className="app-plate w-full" loading="lazy" /> diff --git a/resources/js/pages/DatabaseClient.tsx b/resources/js/pages/DatabaseClient.tsx index c9897b2..d10c396 100644 --- a/resources/js/pages/DatabaseClient.tsx +++ b/resources/js/pages/DatabaseClient.tsx @@ -164,7 +164,7 @@ export default function DatabaseClient({ slug, downloadUrls, githubStars }: Prop alt={`TablePro connected to a ${db.name} database showing the data grid and SQL editor`} width={3024} height={1720} - className="w-full rounded-xl border border-rule shadow-sm" + className="app-plate w-full" /> @@ -177,7 +177,7 @@ export default function DatabaseClient({ slug, downloadUrls, githubStars }: Prop alt={`TablePro connected to a ${db.name} database showing the data grid and SQL editor`} width={3024} height={1720} - className="w-full rounded-xl border border-rule shadow-sm" + className="app-plate w-full" /> @@ -264,13 +264,13 @@ export default function DatabaseClient({ slug, downloadUrls, githubStars }: Prop {`TablePro {`TablePro
@@ -317,13 +317,13 @@ export default function DatabaseClient({ slug, downloadUrls, githubStars }: Prop {`TablePro {`TablePro
diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php index 2ef0aaa..b2cfdcc 100644 --- a/resources/views/app.blade.php +++ b/resources/views/app.blade.php @@ -4,7 +4,12 @@ - + {{-- Matches --background in each theme, computed from the tokens: oklch(1 0 0) + and oklch(0.145 0 0). It tints the browser's own chrome, so an accent + colour here paints the address bar something that appears nowhere on + the page. --}} + + diff --git a/resources/views/og/blog.blade.php b/resources/views/og/blog.blade.php index a44dc57..b36e42b 100644 --- a/resources/views/og/blog.blade.php +++ b/resources/views/og/blog.blade.php @@ -1,3 +1,14 @@ +{{-- + Colours here are literals because this template renders to a PNG through + Chromium and never sees the stylesheet. They correspond to the dark theme's + tokens: #0b0b10 to --background, #f8f8f5 to --foreground, #ffaa46 to + --primary-strong (which computes to #ffa65e — the two are indistinguishable + at 10.4:1 versus 10.2:1 on this ground, so the existing value stays rather + than invalidating 36 committed PNGs). + + Changing any of them means re-running `php artisan og:generate`, which needs + Chromium and writes into a tracked directory. +--}} diff --git a/resources/views/og/compare.blade.php b/resources/views/og/compare.blade.php index 4e9b45f..705a0c1 100644 --- a/resources/views/og/compare.blade.php +++ b/resources/views/og/compare.blade.php @@ -1,3 +1,14 @@ +{{-- + Colours here are literals because this template renders to a PNG through + Chromium and never sees the stylesheet. They correspond to the dark theme's + tokens: #0b0b10 to --background, #f8f8f5 to --foreground, #ffaa46 to + --primary-strong (which computes to #ffa65e — the two are indistinguishable + at 10.4:1 versus 10.2:1 on this ground, so the existing value stays rather + than invalidating 36 committed PNGs). + + Changing any of them means re-running `php artisan og:generate`, which needs + Chromium and writes into a tracked directory. +--}} diff --git a/resources/views/og/database.blade.php b/resources/views/og/database.blade.php index 9e1e229..cb90d9e 100644 --- a/resources/views/og/database.blade.php +++ b/resources/views/og/database.blade.php @@ -1,3 +1,14 @@ +{{-- + Colours here are literals because this template renders to a PNG through + Chromium and never sees the stylesheet. They correspond to the dark theme's + tokens: #0b0b10 to --background, #f8f8f5 to --foreground, #ffaa46 to + --primary-strong (which computes to #ffa65e — the two are indistinguishable + at 10.4:1 versus 10.2:1 on this ground, so the existing value stays rather + than invalidating 36 committed PNGs). + + Changing any of them means re-running `php artisan og:generate`, which needs + Chromium and writes into a tracked directory. +--}} diff --git a/tests/Feature/Landing/LandingStructureTest.php b/tests/Feature/Landing/LandingStructureTest.php new file mode 100644 index 0000000..4ad9afa --- /dev/null +++ b/tests/Feature/Landing/LandingStructureTest.php @@ -0,0 +1,156 @@ +get($ssrUrl . '/health'); + } catch (\Throwable) { + // The health endpoint is optional; only a refused connection matters. + } + + $probe = @fsockopen( + parse_url($ssrUrl, PHP_URL_HOST), + (int) parse_url($ssrUrl, PHP_URL_PORT), + $errno, + $errstr, + 2, + ); + + if ($probe === false) { + $reason = 'SSR service not running. Run: php artisan inertia:start-ssr'; + } else { + fclose($probe); + } + } + + if ($reason !== null) { + if (filter_var(env('REQUIRE_SSR', false), FILTER_VALIDATE_BOOL)) { + Assert::fail($reason); + } + + test()->markTestSkipped($reason); + } + + $response = test()->get('http://' . config('app.web_domain') . '/'); + $response->assertOk(); + + return $html = $response->getContent(); +} + +it('numbers every full-bleed rule, including the accent ones', function (): void { + $html = landingHtml(); + + // Every FullLine and every AccentLine carries the counter class. AccentLine + // was skipped once, which made the ordinal the position of *some* rules + // rather than of the rule — the first one in each section was missing. + $numbered = substr_count($html, 'rule-numbered'); + $rules = substr_count($html, '-ml-[100vw] h-px w-[200vw] bg-rule'); + + expect($numbered)->toBe($rules, 'Every full-bleed rule must carry the ordinal counter'); + expect($numbered)->toBeGreaterThan(80); +}); + +it('subtracts the container padding from the rule ordinals', function (): void { + $html = landingHtml(); + + // Roughly half the rules sit inside a Container and half do not. Without + // --rule-inset the two groups rendered 32px apart, and the Container half + // landed inside the content column instead of the gutter. + expect($html)->toContain('rule-inset-host'); +}); + +it('keeps a download within reach of the reader', function (): void { + $html = landingHtml(); + + // Hero, two rails and the closing CTA. The page once offered two routes to + // /download with roughly three thousand words between them. + expect(substr_count($html, 'href="/download"'))->toBeGreaterThanOrEqual(4); +}); + +it('answers the two blocking objections before the database grid', function (): void { + $html = landingHtml(); + + $free = strpos($html, 'Is it really free, or free for now?'); + $agpl = strpos($html, 'Can I use TablePro at work under AGPLv3?'); + $grid = strpos($html, 'id="databases"'); + + expect($free)->not->toBeFalse(); + expect($agpl)->not->toBeFalse(); + expect($free)->toBeLessThan($grid, 'The free/paid objection must be answered before the feature tour'); + expect($agpl)->toBeLessThan($grid, 'The AGPL objection blocks the highest-value visitor'); +}); + +it('answers the AI question inside the section that raises it', function (): void { + $html = landingHtml(); + + $agents = strpos($html, 'id="mcp"'); + $safety = strpos($html, 'id="safety"'); + $answer = strpos($html, 'Not without you clicking'); + + expect($answer)->not->toBeFalse(); + expect($answer)->toBeGreaterThan($agents); + expect($answer)->toBeLessThan($safety, 'The answer belongs in Agents, not after it'); +}); + +it('gives the page a second ground', function (): void { + $html = landingHtml(); + + // Sixteen sections shared one background until this shipped, so hairline + // density was the only rhythm over ten thousand pixels of scroll. + expect(substr_count($html, 'data-tone="raised"'))->toBeGreaterThanOrEqual(3); +}); + +it('keeps Agents adjacent to the answer to the fear it raises', function (): void { + $html = landingHtml(); + + // 274 words about iCloud sync used to sit between the question and the + // reply. Mobile now lands after Pricing. + expect(strpos($html, 'id="mcp"'))->toBeLessThan(strpos($html, 'id="safety"')); + expect(strpos($html, 'id="pricing"'))->toBeLessThan(strpos($html, 'id="mobile"')); +}); + +it('carries the row treatment only on things that can take focus', function (): void { + $html = landingHtml(); + + // data-row shipped on a div and a tr, so its :focus-visible half could + // never match. It belongs on the tiles, which are links. + expect(substr_count($html, 'data-row'))->toBeGreaterThan(20); + expect($html)->toContain('toContain('>Included<'); + expect($html)->toContain('>Not included<'); +});