From a976fcb7f13f2163ec1b12ef23c445297a11e967 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 15 Aug 2026 10:10:35 +0700 Subject: [PATCH 1/6] feat(landing): give the page a second ground MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sixteen sections shared one background. Over roughly ten thousand pixels of scroll the only rhythm device was hairline density, so a reader could not tell from peripheral vision whether they had moved from Architecture to Agents to Mobile — the difference between restrained and monotonous. `--card`, `--secondary` and `--accent` were declared in the stylesheet and used zero times, which is how long this had been the plan. Two grounds, not five. `raised` goes only to the four sections that already read as inset: Safe Mode, the depth index, the prices and the questions. The token is checked, not assumed. Every foreground was measured against the new surface: the tightest is `--muted-foreground-subtle` at 4.61:1, which still clears AA. `[data-tone="raised"]` has to sit after `.dark` in source order — both are specificity (0,1,0) and the tie breaks on position — and it needs only one rule because `--surface-raised` is itself theme-swapped. Pricing is wrapped in `SectionShell` to get there, which it should have been all along. It was the one landing section that rendered its own header, and it had already drifted: extra spacers the shell has no concept of, and a headline missing `text-pretty`. Every future shell change had been silently skipping the highest-intent section on the page. --- resources/css/app.css | 60 +++++++++++++++++ .../js/components/landing/depth-grid.tsx | 3 +- resources/js/components/landing/faq.tsx | 3 +- resources/js/components/landing/pricing.tsx | 67 ++++++------------- resources/js/components/landing/safety.tsx | 7 +- resources/js/components/ui/section-shell.tsx | 16 ++++- 6 files changed, 104 insertions(+), 52 deletions(-) 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/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/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/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 ( -
+
From 9114e9bbc82d8edbe6f72b6ca7af87790fe1a254 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 15 Aug 2026 10:10:35 +0700 Subject: [PATCH 2/6] feat(landing): shadow the screenshots like windows instead of stickers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 something laid on the page rather than a window sitting above it. Nine images across three files used it. The replacement is a three-layer ramp, which is the shape every reference Mac-app site converged on independently: a tight contact shadow, a mid lift, and one large soft ambient. Warm-tinted rather than neutral black, so it sits in the page's own light instead of muddying it. `drop-shadow`, not `box-shadow`, because the captures carry real transparent macOS squircle corners — I checked, 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. The border and radius come off for the same reason: the image already has its own corners, and `border-radius` is a circular arc tracing a continuous curve, which is visibly wrong where the two meet. --- resources/js/components/landing/hero.tsx | 2 +- resources/js/pages/Compare.tsx | 4 ++-- resources/js/pages/DatabaseClient.tsx | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) 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/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 From 2bfb65080b403bfb913490cb49f90d5493b2f462 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 15 Aug 2026 10:10:51 +0700 Subject: [PATCH 3/6] refactor(ui): one owner for the table rule weights MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three sections carry real tabular data and each hand-rolled its own table shell. That is tolerable where they genuinely differ — the spec result set, the Safe Mode ladder and the plan comparison have different typography and density on purpose, and a primitive that parameterised all of it would be harder to read than the markup it replaced. What was not tolerable is that they had already disagreed on the thing the hairline system exists to settle. Columns are stable structure and take the heavier rule; rows are data and take the hairline. The spec table did that. The plan comparison drew its column separators with the row weight — so the one table where the columns carry the entire meaning was the one drawing them faintest. `DataTable` owns exactly that: the shell, the two weights by name, and a required `caption`. Requiring the caption is the only reliable way to keep it true; a table without one gives a screen reader no idea what it is about to read cells from. The interpolation in spec-strip is worth noting because it typechecks either way: two `className="…${TOKEN}…"` strings were double-quoted rather than template literals, so they would have shipped the literal text as a class name. Caught by grepping the compiled stylesheet for the token names. --- .../js/components/landing/spec-strip.tsx | 18 +++---- resources/js/components/ui/data-table.tsx | 47 +++++++++++++++++++ 2 files changed, 56 insertions(+), 9 deletions(-) create mode 100644 resources/js/components/ui/data-table.tsx 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}
+ ); +} From 864dba86ecd77139a26976da7ea76227ed41d3f9 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 15 Aug 2026 10:10:52 +0700 Subject: [PATCH 4/6] feat(landing): arrow keys across the database tiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twenty-seven tiles, and reaching the one you want meant Tab twenty-seven times. Arrow keys now move between them, up and down by a whole row, Home and End to the ends. The column count is read off `COLS` at the current breakpoint so the two can never disagree. 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 before assistive technology can make sense of what happened — and this is one flat CSS grid whose borders are computed by index, so row wrappers would break the layout outright. Adding the roles without the structure would describe a widget that is not there, which is worse than describing nothing. Focus clamps at the ends rather than wrapping. 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 focus jumping backwards for no reason. The other two grids the plan named do not get this: `depth-grid` and `faq` contain zero interactive elements, so there is nothing to move between. --- .../js/components/landing/database-grid.tsx | 86 ++++++++++++++++++- 1 file changed, 84 insertions(+), 2 deletions(-) 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" From 1f313d72887d9496e8e215087b662c3347cc90f7 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 15 Aug 2026 10:11:11 +0700 Subject: [PATCH 5/6] test: hold the structure the redesign introduced Nothing shipped in the last four PRs had a test. That was survivable while the `ssr` job did not run; it is not survivable now that it does, because the job is the only thing that sees the rendered DOM and it was checking the page as it looked before any of this. Nine assertions, all on the real server-rendered output, all on things a typecheck cannot see: Tailwind silently drops an unknown utility, a CSS counter class is just a string, and a section that stops rendering leaves no trace in `tsc`. Each one guards a defect this work actually shipped and had to repair: - Every full-bleed rule carries the counter, because AccentLine did not and the first rule of every section went unnumbered. - `rule-inset-host` is present, because without it half the ordinals rendered 32px off and inside the content column. - At least four routes to /download exist, because the page once offered two with three thousand words between them. - Both blocking objections precede the feature tour, and the AI answer sits between Agents and Safety rather than after both. - The page has more than one ground. - Agents is adjacent to Safety, and Mobile follows Pricing. - `data-row` appears on more than twenty elements, because it once appeared on two that could not take focus. - Plan availability exists as words, because the checkmark is aria-hidden and every included feature announced as an empty cell. --- .../Feature/Landing/LandingStructureTest.php | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 tests/Feature/Landing/LandingStructureTest.php 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<'); +}); From d2caeefc13742be19b4be9f07a68d7ad519a0a32 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 15 Aug 2026 10:11:11 +0700 Subject: [PATCH 6/6] fix: one source per brand colour, and take the accent out of the browser chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four values claimed to be the brand colour: `--primary` at #f68001, a `theme-color` meta at #FFAA46, #ffaa46 across three OG templates, and a manifest declaring #000000. None of them agreed and none referenced the others. `theme-color` was wrong in kind, not just in value. It tints the browser's own chrome, so an accent there paints the address bar a colour that appears nowhere on the page — and a single value cannot be right for a site that follows the OS. It is now two media-scoped tags carrying the actual backgrounds, #ffffff and #0a0a0a, both computed from the tokens rather than picked. The manifest follows. The OG templates keep #ffaa46 deliberately. It corresponds to the dark theme's `--primary-strong`, which computes to #ffa65e — 10.4:1 against the card ground versus 10.2:1, a difference nobody can see. Changing it would mean regenerating thirty-six committed PNGs through Chromium for nothing. What those files needed was not a new value but a reference, so the next person knows the literals are tokens that could not reach the stylesheet, and that touching them means re-running `og:generate`. --- public/site.webmanifest | 2 +- resources/views/app.blade.php | 7 ++++++- resources/views/og/blog.blade.php | 11 +++++++++++ resources/views/og/compare.blade.php | 11 +++++++++++ resources/views/og/database.blade.php | 11 +++++++++++ 5 files changed, 40 insertions(+), 2 deletions(-) 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/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. +--}}