From e1ed10177181f420f56b6f2e34ae7896ac72210d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 15 Aug 2026 10:44:46 +0700 Subject: [PATCH 1/5] refactor(landing): one header rhythm, shared by every page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nine pages built the section header by hand, forty-one times between them, and every copy had drifted from the one `SectionShell` documents: - The retired `FullLine → h-4 → FullLine` / `FullLine → h-2 → FullLine` pattern was still intact on six pages — four rules where the system draws two, which is a 1px rule, 16px gap, 1px rule band. SectionShell's own docblock calls that a mis-rendered table border and says it was deleted. - None of them imported AccentLine, so the orange tick that marks the start of every section on the homepage was missing from all forty-one. - Every label, heading and lede used `pl-4`, so they sat flush against their rules with no vertical padding — while the homepage gives them `py-3` and `py-4 sm:py-5`. `pl-4` also means no *right* padding, so long text ran into the container rail. - Three h1 size ramps and three padding treatments across the eight pages that have an h1. The fix is one `HeaderStack`, shared. `SectionShell` renders it with an h2 and a landmark; `PageHeader` renders it with an h1; `SectionHeader` renders it alone for the two pages whose sections live inside conditional fragments that no component wrapper lines up with. Duplicating a rhythm is how a rhythm stops being one, so there is now exactly one copy of it. Compare's Verdict section had an eyebrow and no heading at all, unlike every other section on that page. It gets one. Twenty-two spacer divs are gone, including the last two in pricing, and `pl-4` survives only in the comments explaining why it went. --- resources/js/components/landing/footer.tsx | 2 +- resources/js/components/landing/pricing.tsx | 8 +- resources/js/components/ui/section-shell.tsx | 215 ++++++++++++---- resources/js/pages/Blog/Index.tsx | 29 +-- resources/js/pages/Blog/Post.tsx | 40 +-- resources/js/pages/Compare.tsx | 210 +++------------- resources/js/pages/DatabaseClient.tsx | 250 +++---------------- resources/js/pages/Download.tsx | 47 +--- resources/js/pages/Privacy.tsx | 39 +-- resources/js/pages/RefundPolicy.tsx | 39 +-- resources/js/pages/Terms.tsx | 39 +-- 11 files changed, 288 insertions(+), 630 deletions(-) diff --git a/resources/js/components/landing/footer.tsx b/resources/js/components/landing/footer.tsx index e2dbc85..5e82782 100644 --- a/resources/js/components/landing/footer.tsx +++ b/resources/js/components/landing/footer.tsx @@ -115,7 +115,7 @@ export default function Footer() { {/* Copyright */} -

+

© {new Date().getFullYear()} TablePro. All rights reserved.

diff --git a/resources/js/components/landing/pricing.tsx b/resources/js/components/landing/pricing.tsx index ada452e..101eb44 100644 --- a/resources/js/components/landing/pricing.tsx +++ b/resources/js/components/landing/pricing.tsx @@ -339,7 +339,6 @@ export default function Pricing({ paymentProvider, teamMinSeats }: { paymentProv > {/* What a license actually buys. Stated before the prices, on purpose. */} -
@@ -359,7 +358,6 @@ export default function Pricing({ paymentProvider, teamMinSeats }: { paymentProv {/* Billing toggle */} -
@@ -479,12 +477,12 @@ export default function Pricing({ paymentProvider, teamMinSeats }: { paymentProv {/* Refund note */} -

+

7-day money-back guarantee on all paid plans.{' '} Refund policy

-

+

If you use TablePro at work, buy a license. If you cannot afford one, the free version is not going anywhere.

@@ -495,7 +493,7 @@ export default function Pricing({ paymentProvider, teamMinSeats }: { paymentProv -

+

Compare plans

diff --git a/resources/js/components/ui/section-shell.tsx b/resources/js/components/ui/section-shell.tsx index 1fb2cf5..2bd9d28 100644 --- a/resources/js/components/ui/section-shell.tsx +++ b/resources/js/components/ui/section-shell.tsx @@ -3,6 +3,87 @@ import Container from '@/components/ui/container'; import { AccentLine, FullLine } from '@/components/ui/full-line'; import SectionLabel from '@/components/ui/section-label'; +type Tier = 'argument' | 'reference'; + +const HEADLINE_CLASS: Record = { + argument: 'text-3xl sm:text-4xl', + reference: 'text-2xl', +}; + +interface HeaderStackProps { + headingId?: string; + level: 'h1' | 'h2'; + label: ReactNode; + /** Turns the eyebrow into a link. The blog post header uses it to go back. */ + labelHref?: string; + headline: ReactNode; + headlineMuted?: string; + lede?: ReactNode; + tier: Tier; +} + +/** + * The header rhythm, in one place. + * + * Leading space, an accented label rule, the headline, an optional lede, and a + * rule to close. One rule per boundary — this used to emit `FullLine → h-4 → + * FullLine` and again `FullLine → h-2 → FullLine`, so every header drew four + * rules where it needed two, and a 1px rule / 16px gap / 1px rule band does not + * read as a ledger, it reads as a mis-rendered table border. + * + * Separation comes from each row's own vertical padding, which also fixes 30px + * type sitting 3px off its rule while 12px fine print got a full 12px. + * + * Shared between `SectionShell` and `PageHeader` rather than written twice. Six + * pages hand-rolled this stack forty-one times and every copy had drifted: no + * accent tick, no vertical padding, `pl-4` instead of `px-4`, and the retired + * four-rule pattern intact. Duplicating a rhythm is how a rhythm stops being one. + */ +function HeaderStack({ headingId, level, label, labelHref, headline, headlineMuted, lede, tier }: HeaderStackProps) { + const Heading = level; + + return ( + <> +
+ + + + {labelHref ? ( + + {label} + + ) : ( + {label} + )} + + + {headline} + {headlineMuted && ( + <> +
+ {headlineMuted} + + )} +
+ {lede && ( + <> + +

+ {lede} +

+ + )} + +
+ +
+ + ); +} + interface SectionShellProps { id: string; /** Mono uppercase eyebrow. Rendered as a

, never a heading. */ @@ -22,7 +103,7 @@ interface SectionShellProps { * reader scanning for eight seconds had no signal for which sections were * the argument and which were the appendix. */ - tier?: 'argument' | 'reference'; + tier?: Tier; /** * `raised` puts the section on the second ground. * @@ -35,23 +116,7 @@ interface SectionShellProps { children: ReactNode; } -const HEADLINE_CLASS: Record<'argument' | 'reference', string> = { - argument: 'text-3xl sm:text-4xl', - reference: 'text-2xl', -}; - -/** - * The vertical rhythm every landing section shares: leading space, an accented - * label rule, the two-tone headline sandwich, an optional lede, then the - * section body. - * - * One rule per boundary. This used to emit `FullLine → h-4 → FullLine` and - * again `FullLine → h-2 → FullLine`, so every section drew four rules where it - * needed two — and a 1px rule, 16px gap, 1px rule band does not read as a - * ledger, it reads as a mis-rendered table border. Separation now comes from - * the rows' own vertical padding, which also fixes 30px type sitting 3px off - * its rule while 12px fine print got 12px. - */ +/** A section of a page, with an H2 and a landmark. */ export default function SectionShell({ id, label, @@ -72,38 +137,92 @@ export default function SectionShell({ data-tone={tone === 'raised' ? 'raised' : undefined} className={`scroll-mt-20 ${className ?? ''}`} > -

- - - - {label} - -

- {headline} - {headlineMuted && ( - <> -
- {headlineMuted} - - )} -

- {lede && ( - <> - -

- {lede} -

- - )} - -
+ + {children} + + ); +} -
+interface PageHeaderProps { + label: ReactNode; + labelHref?: string; + headline: string; + headlineMuted?: string; + /** + * The lede, or a metadata line such as "Last updated: May 2026". Either way + * it is the row under the headline and gets the same treatment; the pages + * that hand-rolled this gave the two roles different type and padding for + * no reason anyone recorded. + * + * A node rather than a string, because one of them carries a link. + */ + lede?: ReactNode; + children?: ReactNode; +} +/** + * The same stack, opening a page rather than a section, so it carries the H1. + * + * Eight pages built this by hand and none of them agreed: three h1 size ramps, + * three padding treatments, and the four-rule pattern this component's own + * docblock says was deleted. + */ +export function PageHeader({ label, labelHref, headline, headlineMuted, lede, children }: PageHeaderProps) { + return ( + <> + {children} - + + ); +} + +interface SectionHeaderProps { + label: ReactNode; + headline: ReactNode; + headlineMuted?: string; + lede?: ReactNode; + tier?: Tier; +} + +/** + * The header stack on its own, for a section that cannot be wrapped. + * + * `SectionShell` is the right thing wherever the section's body is a single + * block, because it also supplies the landmark, the id and the scroll offset. + * Compare and DatabaseClient build their sections inside conditional fragments + * whose boundaries do not line up with a component wrapper, so they take the + * header alone rather than keeping a second, drifting copy of the rhythm. + * + * The heading gets no id here: without the `
` that + * SectionShell provides there is nothing to point at it, and an id that labels + * nothing is worse than none. + */ +export function SectionHeader({ label, headline, headlineMuted, lede, tier = 'argument' }: SectionHeaderProps) { + return ( + ); } diff --git a/resources/js/pages/Blog/Index.tsx b/resources/js/pages/Blog/Index.tsx index 34635b8..a03dce1 100644 --- a/resources/js/pages/Blog/Index.tsx +++ b/resources/js/pages/Blog/Index.tsx @@ -4,7 +4,7 @@ import Footer from '@/components/landing/footer'; import Container from '@/components/ui/container'; import SEOHead from '@/components/seo/seo-head'; import { Link } from '@inertiajs/react'; -import SectionLabel from '@/components/ui/section-label'; +import { PageHeader } from '@/components/ui/section-shell'; import { FullLine } from '@/components/ui/full-line'; interface PostSummary { @@ -58,31 +58,8 @@ export default function BlogIndex({ posts, downloadUrls, githubStars }: Props) { { name: 'Blog', path: '/blog' }, ]} /> - -
- - - - - Blog - - - - - -

- Database tools, SQL, and Mac. -

-
- - - -

- {BLOG_DESCRIPTION} -

-
- - + + {posts.length === 0 ? (
diff --git a/resources/js/pages/Blog/Post.tsx b/resources/js/pages/Blog/Post.tsx index 0c20168..4ca3570 100644 --- a/resources/js/pages/Blog/Post.tsx +++ b/resources/js/pages/Blog/Post.tsx @@ -5,6 +5,7 @@ import Container from '@/components/ui/container'; import SEOHead from '@/components/seo/seo-head'; import { Link } from '@inertiajs/react'; import SectionLabel from '@/components/ui/section-label'; +import { PageHeader } from '@/components/ui/section-shell'; import { FullLine } from '@/components/ui/full-line'; import Button from '@/components/ui/button'; @@ -98,38 +99,21 @@ export default function BlogPost({ post, relatedPosts, downloadUrls, githubStars { name: post.title, path: post.url }, ]} /> - -
- - - - - ← Blog - - - - - -

- {post.title} -

-
- - - -
- {post.author} + + {post.author} {post.readingMinutes} min read -
-
- - {post.tags.length > 0 && ( + + } + /> +{post.tags.length > 0 && ( <> diff --git a/resources/js/pages/Compare.tsx b/resources/js/pages/Compare.tsx index 174cdcd..afffe41 100644 --- a/resources/js/pages/Compare.tsx +++ b/resources/js/pages/Compare.tsx @@ -4,7 +4,7 @@ import Footer from '@/components/landing/footer'; import Container from '@/components/ui/container'; import SEOHead from '@/components/seo/seo-head'; import { getComparisonBySlug } from '@/data/comparisons'; -import SectionLabel from '@/components/ui/section-label'; +import { PageHeader, SectionHeader } from '@/components/ui/section-shell'; import { FullLine } from '@/components/ui/full-line'; import Button from '@/components/ui/button'; @@ -134,39 +134,8 @@ export default function Compare({ slug, downloadUrls, githubStars }: Props) { { name: comp.name, path: canonical }, ]} /> - - {/* Hero */} -
- - - - - Compare - - - - -
- - - -

- {comp.tagline} -

- -
- -
- - - -

- {comp.longDescription} -

- -
- - {/* Comparison Table */} + +{/* Comparison Table */}
@@ -208,28 +177,10 @@ export default function Compare({ slug, downloadUrls, githubStars }: Props) { {/* Benchmarks */} {comp.benchmarks && ( <> -
- - - - - Benchmarks - - - - -
- - - -

- The numbers. -

- -
- -
- +
@@ -260,28 +211,10 @@ export default function Compare({ slug, downloadUrls, githubStars }: Props) { )} {/* Pros */} -
- - - - - Summary - - - - -
- - - -

- Strengths of each. -

- -
- -
- +
@@ -317,28 +250,10 @@ export default function Compare({ slug, downloadUrls, githubStars }: Props) { {/* Migration guide */} {comp.migrationSteps && comp.migrationSteps.length > 0 && ( <> -
- - - - - Migration - - - - -
- - - -

- Switch from {comp.name}. -

- -
- -
- + Switch from {comp.name}.} + />
    @@ -360,28 +275,10 @@ export default function Compare({ slug, downloadUrls, githubStars }: Props) { )} {/* App Screenshot */} -
    - - - - - TablePro - - - - -
    - - - -

    - See it in action. -

    - -
    - -
    - + @@ -416,28 +313,10 @@ export default function Compare({ slug, downloadUrls, githubStars }: Props) { {/* Who Should Choose What */} -
    - - - - - Decision - - - - -
    - - - -

    - Which one is right for you? -

    - -
    - -
    - +
    @@ -452,21 +331,8 @@ export default function Compare({ slug, downloadUrls, githubStars }: Props) {
    - - {/* Verdict */} -
    - - - - - Verdict - - - - -
    - - + +

    @@ -479,28 +345,10 @@ export default function Compare({ slug, downloadUrls, githubStars }: Props) { {/* FAQ */} {comp.faqs && comp.faqs.length > 0 && ( <> -

    - - - - - FAQ - - - - -
    - - - -

    - Common questions. -

    - -
    - -
    - +
    diff --git a/resources/js/pages/DatabaseClient.tsx b/resources/js/pages/DatabaseClient.tsx index d10c396..15c0010 100644 --- a/resources/js/pages/DatabaseClient.tsx +++ b/resources/js/pages/DatabaseClient.tsx @@ -4,7 +4,7 @@ import Footer from '@/components/landing/footer'; import Container from '@/components/ui/container'; import SEOHead from '@/components/seo/seo-head'; import { getDatabaseBySlug } from '@/data/databases'; -import SectionLabel from '@/components/ui/section-label'; +import { PageHeader, SectionHeader } from '@/components/ui/section-shell'; import { FullLine } from '@/components/ui/full-line'; import Button from '@/components/ui/button'; @@ -96,40 +96,18 @@ export default function DatabaseClient({ slug, downloadUrls, githubStars }: Prop /> {/* Hero */} -
    - - - -
    - {db.name} - + + {db.name} {db.name} Client - -
    - -
    - -
    - - - -

    - {db.tagline} -

    - -
    - -
    - - - -

    - {db.longDescription} -

    - -
    + + } + headline={db.tagline} + lede={db.longDescription} + /> -
    +
    @@ -184,28 +162,10 @@ export default function DatabaseClient({ slug, downloadUrls, githubStars }: Prop {/* Features */} -
    - - - - - Features - - - - -
    - - - -

    - Built for {db.name}. -

    - -
    - -
    - + Built for {db.name}.} + />
    @@ -225,39 +185,11 @@ export default function DatabaseClient({ slug, downloadUrls, githubStars }: Prop {/* Data Grid Screenshot */} -
    - - - - - Data Grid - - - - -
    - - - -

    - Browse and edit data. -

    - -
    - -
    - - - -

    - Sort, filter, and edit {db.name} data in a spreadsheet-like grid. Click any cell to edit. - Insert and delete rows. Review changes before saving. -

    - -
    - -
    - + Sort, filter, and edit {db.name} data in a spreadsheet-like grid. Click any cell to edit. Insert and delete rows. Review changes before saving.} + />
    @@ -278,39 +210,11 @@ export default function DatabaseClient({ slug, downloadUrls, githubStars }: Prop {/* SQL Editor Screenshot */} -
    - - - - - SQL Editor - - - - -
    - - - -

    - Write queries faster. -

    - -
    - -
    - - - -

    - Tree-sitter syntax highlighting, schema-aware autocomplete, multi-tab, Vim mode, and full-text query history. - AI assistant can write, explain, or optimize your SQL. -

    - -
    - -
    - +
    @@ -331,28 +235,10 @@ export default function DatabaseClient({ slug, downloadUrls, githubStars }: Prop {/* What You Can Do */} -
    - - - - - Capabilities - - - - -
    - - - -

    - What you can do. -

    - -
    - -
    - +
    @@ -371,28 +257,10 @@ export default function DatabaseClient({ slug, downloadUrls, githubStars }: Prop {/* Connection Info */} -
    - - - - - Connect - - - - -
    - - - -

    - Get connected in seconds. -

    - -
    - -
    - +
    @@ -434,28 +302,10 @@ export default function DatabaseClient({ slug, downloadUrls, githubStars }: Prop {/* How to connect */} {db.howToSteps && db.howToSteps.length > 0 && ( <> -
    - - - - - How to - - - - -
    - - - -

    - Connect to {db.name} in four steps. -

    - -
    - -
    - + Connect to {db.name} in four steps.} + />
      @@ -479,28 +329,10 @@ export default function DatabaseClient({ slug, downloadUrls, githubStars }: Prop {/* FAQ */} {db.faqs && db.faqs.length > 0 && ( <> -
      - - - - - FAQ - - - - -
      - - - -

      - Common {db.name} questions. -

      - -
      - -
      - + Common {db.name} questions.} + />
      diff --git a/resources/js/pages/Download.tsx b/resources/js/pages/Download.tsx index c256283..530d8a4 100644 --- a/resources/js/pages/Download.tsx +++ b/resources/js/pages/Download.tsx @@ -4,7 +4,7 @@ import Header from '@/components/landing/header'; import Footer from '@/components/landing/footer'; import Container from '@/components/ui/container'; import SEOHead from '@/components/seo/seo-head'; -import SectionLabel from '@/components/ui/section-label'; +import { PageHeader } from '@/components/ui/section-shell'; import { FullLine } from '@/components/ui/full-line'; import Button, { buttonClasses } from '@/components/ui/button'; @@ -119,42 +119,17 @@ export default function Download({ downloadUrls, githubStars }: Props) { { name: 'Download', path: '/download' }, ]} /> - -
      - - {/* Label */} - - - - Download - - - - - {/* Spacer */} -
      - - {/* Headline */} - - -

      - Your download is starting... -

      - -
      - - {/* Manual link */} -
      - - -

      - If your download doesn't start automatically,{' '} + + If your download doesn't start automatically,{' '} click here. -

      - -
      - - {/* Spacer */} + + } + /> +{/* Spacer */}
      {/* Architecture selector */} diff --git a/resources/js/pages/Privacy.tsx b/resources/js/pages/Privacy.tsx index c10e9f9..d2f30e9 100644 --- a/resources/js/pages/Privacy.tsx +++ b/resources/js/pages/Privacy.tsx @@ -3,7 +3,7 @@ import Header from '@/components/landing/header'; import Footer from '@/components/landing/footer'; import Container from '@/components/ui/container'; import SEOHead from '@/components/seo/seo-head'; -import SectionLabel from '@/components/ui/section-label'; +import { PageHeader } from '@/components/ui/section-shell'; import { FullLine } from '@/components/ui/full-line'; interface Props { @@ -38,37 +38,12 @@ export default function Privacy({ downloadUrls }: Props) { { name: 'Privacy', path: '/privacy' }, ]} /> - -
      - - - - - Legal - - - - -
      - - - -

      - Privacy Policy -

      - -
      - -
      - - -

      - Last updated: May 2026 -

      - -
      - -
      + +
      diff --git a/resources/js/pages/RefundPolicy.tsx b/resources/js/pages/RefundPolicy.tsx index e177b6e..f7bfb44 100644 --- a/resources/js/pages/RefundPolicy.tsx +++ b/resources/js/pages/RefundPolicy.tsx @@ -3,7 +3,7 @@ import Header from '@/components/landing/header'; import Footer from '@/components/landing/footer'; import Container from '@/components/ui/container'; import SEOHead from '@/components/seo/seo-head'; -import SectionLabel from '@/components/ui/section-label'; +import { PageHeader } from '@/components/ui/section-shell'; import { FullLine } from '@/components/ui/full-line'; interface Props { @@ -22,37 +22,12 @@ export default function RefundPolicy({ downloadUrls }: Props) { { name: 'Refund Policy', path: '/refund-policy' }, ]} /> - -
      - - - - - Legal - - - - -
      - - - -

      - Refund Policy -

      - -
      - -
      - - -

      - Last updated: May 2026 -

      - -
      - -
      + +
      diff --git a/resources/js/pages/Terms.tsx b/resources/js/pages/Terms.tsx index 7b13437..4599741 100644 --- a/resources/js/pages/Terms.tsx +++ b/resources/js/pages/Terms.tsx @@ -3,7 +3,7 @@ import Header from '@/components/landing/header'; import Footer from '@/components/landing/footer'; import Container from '@/components/ui/container'; import SEOHead from '@/components/seo/seo-head'; -import SectionLabel from '@/components/ui/section-label'; +import { PageHeader } from '@/components/ui/section-shell'; import { FullLine } from '@/components/ui/full-line'; interface Props { @@ -38,37 +38,12 @@ export default function Terms({ downloadUrls }: Props) { { name: 'Terms', path: '/terms' }, ]} /> - -
      - - - - - Legal - - - - -
      - - - -

      - Terms of Service -

      - -
      - -
      - - -

      - Last updated: May 2026 -

      - -
      - -
      + +
      From 048ba5a481e5f22fa62334262e12d3cf47f1b066 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 15 Aug 2026 10:49:25 +0700 Subject: [PATCH 2/5] refactor(ui): one copy of each thing that was drawn many times MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four pieces of markup existed in two to eight copies each, and every set had drifted. **The Apple mark, eight copies in four shapes.** Four of them carried no size class at all, which inside a flex button means the browser guesses; half were missing `aria-hidden`, so a screen reader announced an unlabelled graphic in the middle of a button that already reads "Download for Mac". **The tick, three copies that disagreed on colour.** Two used `--primary-strong`; one used bare `--primary` at 2.62:1 — the failure `SectionLabel` was created to stop, surviving in the one glyph that is the sole carrier of meaning in a comparison column. That last point is why availability is now a single `Availability` component rather than a glyph you are trusted to pair with text. Both glyphs are `aria-hidden`, so a cell holding only one announces as empty — and an empty cell in a comparison table does not read as "no answer", it reads as "not included". The pricing table shipped exactly that, was fixed, and Compare lost the same fix partway through this very commit when its local wrappers were swapped out. A pair that must not be separated should not be two things. **The FAQ, presented two ways.** An open two-column grid on the homepage and `/faq`, a `
      ` accordion on the comparison and database pages — the two accordions byte-identical, and both invalid, since a `
      ` may contain only `
      `, `
      ` and `
      `. Open won: these pages carry three to eight questions, so there is nothing to collapse for, and an answer behind a disclosure is one a search engine ranks lower and a skimming reader never sees. **`SectionBlock` and `Bullet`, byte-identical in Privacy and Terms.** Two places for one rule to drift, and two places to fix it when it did. The mobile nav keeps its own close icon. It shares a path with the "not included" cross and nothing else — 20px inheriting the button's colour versus 14px in muted-foreground — and sharing a path is not sharing a role. --- .../js/components/landing/download-rail.tsx | 8 +- resources/js/components/landing/faq.tsx | 25 +----- .../js/components/landing/footer-cta.tsx | 8 +- resources/js/components/landing/header.tsx | 5 +- resources/js/components/landing/hero.tsx | 8 +- resources/js/components/landing/pricing.tsx | 33 ++----- resources/js/components/ui/faq-list.tsx | 50 +++++++++++ resources/js/components/ui/glyph.tsx | 87 +++++++++++++++++++ resources/js/components/ui/prose-block.tsx | 33 +++++++ resources/js/pages/Blog/Post.tsx | 3 +- resources/js/pages/Compare.tsx | 58 ++----------- resources/js/pages/DatabaseClient.tsx | 24 ++--- resources/js/pages/Download.tsx | 3 +- resources/js/pages/Privacy.tsx | 83 ++++++++---------- resources/js/pages/Terms.tsx | 83 ++++++++---------- 15 files changed, 269 insertions(+), 242 deletions(-) create mode 100644 resources/js/components/ui/faq-list.tsx create mode 100644 resources/js/components/ui/glyph.tsx create mode 100644 resources/js/components/ui/prose-block.tsx diff --git a/resources/js/components/landing/download-rail.tsx b/resources/js/components/landing/download-rail.tsx index 471a93a..a60ee10 100644 --- a/resources/js/components/landing/download-rail.tsx +++ b/resources/js/components/landing/download-rail.tsx @@ -1,14 +1,8 @@ import Button from '@/components/ui/button'; import Container from '@/components/ui/container'; import { FullLine } from '@/components/ui/full-line'; +import { AppleGlyph } from '@/components/ui/glyph'; -function AppleGlyph() { - return ( - - ); -} interface Props { /** diff --git a/resources/js/components/landing/faq.tsx b/resources/js/components/landing/faq.tsx index ce2fb3c..14e408d 100644 --- a/resources/js/components/landing/faq.tsx +++ b/resources/js/components/landing/faq.tsx @@ -1,5 +1,6 @@ import Container from '@/components/ui/container'; import { FullLine } from '@/components/ui/full-line'; +import FaqList from '@/components/ui/faq-list'; import SectionShell from '@/components/ui/section-shell'; import { faqs as defaultFaqs, type FaqItem } from '@/data/faqs'; @@ -10,41 +11,19 @@ interface Props { headlineMuted?: string; } -function Column({ items, className }: { items: FaqItem[]; className?: string }) { - return ( -
      - {items.map((faq, i) => ( -
      -

      {faq.question}

      -

      {faq.answer}

      -
      - ))} -
      - ); -} export default function FAQ({ items = defaultFaqs, headline = 'Questions people actually ask.', headlineMuted = 'Short answers.', }: Props) { - const mid = Math.ceil(items.length / 2); return ( -
      - - -
      +
      diff --git a/resources/js/components/landing/footer-cta.tsx b/resources/js/components/landing/footer-cta.tsx index 8de7a90..20c89bb 100644 --- a/resources/js/components/landing/footer-cta.tsx +++ b/resources/js/components/landing/footer-cta.tsx @@ -7,6 +7,7 @@ import { FullLine } from '@/components/ui/full-line'; import { cellBorders, GridCell, type ColumnMap } from '@/components/ui/grid-cell'; import SectionShell from '@/components/ui/section-shell'; import Button from '@/components/ui/button'; +import { AppleGlyph } from '@/components/ui/glyph'; interface FlashMessage { type: 'success' | 'warning' | 'error'; @@ -34,13 +35,6 @@ function FlashStatus({ flash }: { flash?: FlashMessage | null }) { ); } -function AppleGlyph() { - return ( - - ); -} /** * A minimal stand-in for Inertia's useForm, used by the two anonymous email diff --git a/resources/js/components/landing/header.tsx b/resources/js/components/landing/header.tsx index 0db741b..83de82f 100644 --- a/resources/js/components/landing/header.tsx +++ b/resources/js/components/landing/header.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useRef } from 'react'; import { buttonClasses } from '@/components/ui/button'; import MobileNav from './mobile-nav'; +import { AppleGlyph } from '@/components/ui/glyph'; interface Props { /** @@ -163,9 +164,7 @@ export default function Header({ githubStars }: Props) { requestAnimationFrame(() => window.scrollTo({ top: scrollY, behavior: 'instant' })); }} > - +
      Download for Mac
      macOS 14+
      diff --git a/resources/js/components/landing/hero.tsx b/resources/js/components/landing/hero.tsx index 987b33a..20473ad 100644 --- a/resources/js/components/landing/hero.tsx +++ b/resources/js/components/landing/hero.tsx @@ -4,6 +4,7 @@ import { AccentLine, FullLine } from '@/components/ui/full-line'; import ThemedImage from '@/components/ui/themed-image'; import SectionLabel from '@/components/ui/section-label'; import Button from '@/components/ui/button'; +import { AppleGlyph } from '@/components/ui/glyph'; interface Props { githubStars?: number | null; @@ -32,13 +33,6 @@ function formatReleaseDate(iso: string): string { .toUpperCase(); } -function AppleGlyph() { - return ( - - ); -} export default function Hero({ githubStars, latestRelease }: Props) { const eyebrow = [ diff --git a/resources/js/components/landing/pricing.tsx b/resources/js/components/landing/pricing.tsx index 101eb44..dce816b 100644 --- a/resources/js/components/landing/pricing.tsx +++ b/resources/js/components/landing/pricing.tsx @@ -5,6 +5,7 @@ import DataTable from '@/components/ui/data-table'; import { FullLine } from '@/components/ui/full-line'; import { Ledger, LedgerRow } from '@/components/ui/ledger'; import SectionShell from '@/components/ui/section-shell'; +import { Availability, CheckGlyph } from '@/components/ui/glyph'; type BillingCycle = 'monthly' | 'yearly' | 'lifetime'; @@ -103,20 +104,6 @@ const comparisonFeatures = [ { name: 'Priority support', free: false, pro: false, team: true }, ]; -function CheckIcon({ className }: { className?: string }) { - return ( - - ); -} function PricingCard({ tier, cycle, discountCode, discount, paymentProvider, teamMinSeats }: { tier: Tier; cycle: BillingCycle; discountCode: string; discount: Discount | null; paymentProvider: string; teamMinSeats: number }) { // Narrow off tier.price directly: TypeScript does not carry the refinement @@ -250,7 +237,7 @@ function PricingCard({ tier, cycle, discountCode, discount, paymentProvider, tea
        {tier.features?.map((feature) => (
      • - + {feature}
      • ))} @@ -566,20 +553,10 @@ export default function Pricing({ paymentProvider, teamMinSeats }: { paymentProv > {typeof value === 'string' ? ( {value} - ) : value ? ( - <> - Included - - - - ) : ( - <> - Not included - - + + + )} ); diff --git a/resources/js/components/ui/faq-list.tsx b/resources/js/components/ui/faq-list.tsx new file mode 100644 index 0000000..da6e54c --- /dev/null +++ b/resources/js/components/ui/faq-list.tsx @@ -0,0 +1,50 @@ +import { cn } from '@/lib/utils'; +import type { FaqItem } from '@/data/faqs'; + +function Column({ items, className }: { items: FaqItem[]; className?: string }) { + return ( +
        + {items.map((faq, i) => ( +
        +

        {faq.question}

        +

        {faq.answer}

        +
        + ))} +
        + ); +} + +/** + * Questions and answers, both visible. + * + * The site used to present these two ways: an open two-column grid on the + * homepage and `/faq`, and a `
        ` accordion on the comparison and + * database pages — byte-identical between those two, and invalid in both, since + * a `
        ` may contain only `
        `, `
        ` and `
        `. + * + * Open won. These pages carry three to eight questions, so there is nothing to + * collapse for, and an answer behind a disclosure is an answer a search engine + * ranks lower and a skimming reader never sees. The disclosure also gave the + * only affordance on the page that changed shape on click without saying so. + * + * Two columns above md when there is enough to split; one below, and one always + * when there are three or fewer. + */ +export default function FaqList({ items }: { items: FaqItem[] }) { + const split = items.length > 3; + const mid = Math.ceil(items.length / 2); + + if (!split) { + return ; + } + + return ( +
        + + +
        + ); +} diff --git a/resources/js/components/ui/glyph.tsx b/resources/js/components/ui/glyph.tsx new file mode 100644 index 0000000..a1dfe03 --- /dev/null +++ b/resources/js/components/ui/glyph.tsx @@ -0,0 +1,87 @@ +import { cn } from '@/lib/utils'; + +interface GlyphProps { + className?: string; +} + +/** + * The Apple mark, for the "Download for Mac" buttons. + * + * It was inlined eight times in four shapes, and four of those copies carried + * no size class at all — inside a flex button, an SVG with no width collapses + * to whatever the browser guesses. Half of them were also missing + * `aria-hidden`, so a screen reader read an unlabelled graphic in the middle of + * a button that already says "Download for Mac". + * + * `size-4` is the default because that is what the buttons around it use; the + * hero passes `size-5` because its button is a rung larger. + */ +export function AppleGlyph({ className }: GlyphProps) { + return ( + + ); +} + +/** + * The "included" tick. + * + * Three copies existed and they did not agree on colour: two used + * `--primary-strong`, one used bare `--primary` at 2.62:1 — the exact failure + * `SectionLabel` was created to stop, surviving in a glyph that is the sole + * carrier of meaning in a comparison column. + * + * `aria-hidden` on purpose. Wherever this appears the cell must also carry + * visually hidden text, because a tick with no text alternative announces as + * nothing and the reader is told the feature is absent. + */ +export function CheckGlyph({ className }: GlyphProps) { + return ( + + ); +} + +/** The "not included" cross. Same rule: it needs text beside it, not instead of it. */ +export function CrossGlyph({ className }: GlyphProps) { + return ( + + ); +} + +/** + * Availability in a comparison cell: the glyph plus the word. + * + * This pair exists as one component because separating them is exactly how the + * bug keeps coming back. Both glyphs are `aria-hidden`, so a cell holding only + * one announces as empty — and an empty cell in a comparison table does not + * read as "no answer", it reads as "not included". The pricing table shipped + * that way, was fixed, and Compare then lost the same fix when its local + * wrappers were replaced. + */ +export function Availability({ included }: { included: boolean }) { + return ( + <> + {included ? 'Included' : 'Not included'} + {included ? : } + + ); +} diff --git a/resources/js/components/ui/prose-block.tsx b/resources/js/components/ui/prose-block.tsx new file mode 100644 index 0000000..1f09b66 --- /dev/null +++ b/resources/js/components/ui/prose-block.tsx @@ -0,0 +1,33 @@ +import { ReactNode } from 'react'; +import { FullLine } from '@/components/ui/full-line'; + +/** + * A titled block of body copy, ruled off from the one above it. + * + * The legal pages are a stack of these. Privacy and Terms each declared a + * byte-identical local copy, which is two places for one rule to drift and two + * places to fix when it does. + * + * The heading is an h2 because these sit under the page's single h1. + */ +export function ProseBlock({ title, children }: { title: string; children: ReactNode }) { + return ( + <> + +
        +

        {title}

        +
        {children}
        +
        + + ); +} + +/** + * The marker on a bulleted line inside a ProseBlock. + * + * `mt-1.5` aligns the dot to the cap height of the first line rather than to + * the line box, which is why it is not a list-style marker. + */ +export function Bullet() { + return
        diff --git a/resources/js/pages/Compare.tsx b/resources/js/pages/Compare.tsx index afffe41..2191243 100644 --- a/resources/js/pages/Compare.tsx +++ b/resources/js/pages/Compare.tsx @@ -7,6 +7,8 @@ import { getComparisonBySlug } from '@/data/comparisons'; import { PageHeader, SectionHeader } from '@/components/ui/section-shell'; import { FullLine } from '@/components/ui/full-line'; import Button from '@/components/ui/button'; +import { AppleGlyph, Availability, CheckGlyph } from '@/components/ui/glyph'; +import FaqList from '@/components/ui/faq-list'; interface Props { slug: string; @@ -24,44 +26,10 @@ interface Props { * `muted-foreground/30` measured 1.51:1: both were effectively invisible to * anyone who needed them most. */ -function Check() { - return ( - <> - Included - - - ); -} -function Cross() { - return ( - <> - Not included - - - ); -} function CellValue({ value }: { value: string | boolean }) { - if (typeof value === 'boolean') return value ? : ; + if (typeof value === 'boolean') return ; return {value}; } @@ -224,7 +192,7 @@ export default function Compare({ slug, downloadUrls, githubStars }: Props) {
          {comp.prosTablePro.map((pro) => (
        • - + {pro}
        • ))} @@ -237,7 +205,7 @@ export default function Compare({ slug, downloadUrls, githubStars }: Props) {
            {comp.prosCompetitor.map((pro) => (
          • - + {pro}
          • ))} @@ -351,19 +319,7 @@ export default function Compare({ slug, downloadUrls, githubStars }: Props) { /> -
            - {comp.faqs.map((faq) => ( -
            - - {faq.question} - - - - -

            {faq.answer}

            -
            - ))} -
            +
            @@ -385,7 +341,7 @@ export default function Compare({ slug, downloadUrls, githubStars }: Props) {
      diff --git a/resources/js/pages/DatabaseClient.tsx b/resources/js/pages/DatabaseClient.tsx index 15c0010..3897d9e 100644 --- a/resources/js/pages/DatabaseClient.tsx +++ b/resources/js/pages/DatabaseClient.tsx @@ -7,6 +7,8 @@ import { getDatabaseBySlug } from '@/data/databases'; import { PageHeader, SectionHeader } from '@/components/ui/section-shell'; import { FullLine } from '@/components/ui/full-line'; import Button from '@/components/ui/button'; +import { AppleGlyph, CheckGlyph } from '@/components/ui/glyph'; +import FaqList from '@/components/ui/faq-list'; interface Props { slug: string; @@ -115,7 +117,7 @@ export default function DatabaseClient({ slug, downloadUrls, githubStars }: Prop
      - +

      TablePro is the data controller for personal information described in this policy. For privacy questions, email{' '} hello@tablepro.app.

      -
      + - +

      Anonymous usage analytics (desktop app)

      The Application can send anonymous usage analytics. It is{' '} @@ -115,9 +102,9 @@ export default function Privacy({ downloadUrls }: Props) {

      Our servers record standard request logs (IP address, user agent, timestamp, URL) for security and abuse prevention. These logs are retained for up to 90 days.

      -
      + - +
      • No database contents or queries. SQL you write and data you fetch never leave your machine.
      • No connection credentials. Database hosts, usernames, and passwords stay in the macOS Keychain.
      • @@ -125,9 +112,9 @@ export default function Privacy({ downloadUrls }: Props) {
      • No crash reports sent to any third party.
      • No third-party trackers. No Google Analytics, Mixpanel, Sentry, or similar SDK in the desktop app.
      -
      + - +
      • Anonymous analytics: understand which app versions, OS versions, and database types our users run, to prioritise compatibility and bug fixes.
      • License validation: confirm a License Key is valid and active.
      • @@ -136,9 +123,9 @@ export default function Privacy({ downloadUrls }: Props) {
      • Newsletter and announcements: only if you subscribed.
      • Security: detect abuse and unauthorised access attempts.
      -
      + - +

      For users in the European Economic Area or the United Kingdom, the legal bases under GDPR / UK GDPR are:

      • Contract (Art. 6(1)(b)): processing payment, providing the License Key, account portal access.
      • @@ -146,9 +133,9 @@ export default function Privacy({ downloadUrls }: Props) {
      • Consent (Art. 6(1)(a)): newsletter subscriptions, optional features you enable.
      • Legal obligation (Art. 6(1)(c)): tax records, responses to lawful requests.
      -
      + - +

      We share personal data only with the providers needed to operate the Services:

      • LemonSqueezy or Polar: payment processing for License Key purchases.
      • @@ -159,15 +146,15 @@ export default function Privacy({ downloadUrls }: Props) {

        We do not sell, rent, or share personal data with advertisers.

        - + - +

        Our servers operate in multiple regions. When you interact with TablePro, your data may be transferred to or processed in countries outside your own. Where required, transfers from the EEA / UK rely on Standard Contractual Clauses or other approved mechanisms.

        -
        + - +
        • Anonymous analytics: aggregated indefinitely; the SHA-256 machine ID has no link to your identity.
        • Account and license data: kept while your license is active and for up to 7 years afterward for tax and audit purposes.
        • @@ -175,9 +162,9 @@ export default function Privacy({ downloadUrls }: Props) {
        • Server logs: 90 days.
        • Support emails: 2 years from the last interaction.
        -
        + - +

        Subject to local law, you have the right to:

        @@ -195,28 +182,28 @@ export default function Privacy({ downloadUrls }: Props) { To exercise any of these rights, email{' '} hello@tablepro.app. We respond within 30 days.

        -
        + - +

        If you are a California resident, the California Consumer Privacy Act gives you the right to know what personal information we collect, to request deletion, to opt out of "sale" of personal information, and to non-discrimination for exercising these rights. We do not sell personal information.

        -
        + - +

        TablePro is not directed at children under 16. We do not knowingly collect personal data from children. If you believe a child has provided us data, contact us and we will delete it.

        -
        + - +

        We use industry-standard measures to protect data: HTTPS for all network traffic, password hashing with modern algorithms, scoped API tokens, and least-privilege access for our team. No system is perfectly secure; if you suspect a vulnerability, email{' '} hello@tablepro.app.

        -
        + - +

        The marketing site uses two functional cookies. No tracking, no advertising, no third-party SDKs.

        @@ -224,9 +211,9 @@ export default function Privacy({ downloadUrls }: Props) {
      • nl_dismissed_at (90 days): records when you dismissed the newsletter prompt so we don't reshow it. Lawful basis: legitimate interest.
      • nl_subscribed (365 days): records that you subscribed so we don't reprompt. Lawful basis: legitimate interest, performance of a subscription you initiated.
      -
      + - +

      Sensitive data stays on your Mac:

      • Database credentials: macOS Keychain.
      • @@ -234,28 +221,28 @@ export default function Privacy({ downloadUrls }: Props) {
      • App settings: standard macOS UserDefaults.
      • Tab state: local JSON files for session restore.
      -
      + - +

      The Application source is on{' '} GitHub under the AGPLv3. The analytics code is in{' '} TablePro/Core/Services/AnalyticsService.swift. You can verify what is sent.

      -
      + - +

      We may update this policy. Material changes will be posted on this page with a new "Last updated" date and, where required by law, notified to users.

      -
      + - +

      For privacy questions, security reports, or anything else, email{' '} hello@tablepro.app.

      -
      + diff --git a/resources/js/pages/Terms.tsx b/resources/js/pages/Terms.tsx index 4599741..4540d3c 100644 --- a/resources/js/pages/Terms.tsx +++ b/resources/js/pages/Terms.tsx @@ -5,26 +5,13 @@ import Container from '@/components/ui/container'; import SEOHead from '@/components/seo/seo-head'; import { PageHeader } from '@/components/ui/section-shell'; import { FullLine } from '@/components/ui/full-line'; +import { Bullet, ProseBlock } from '@/components/ui/prose-block'; interface Props { downloadUrls: { arm64: string; x86_64: string }; } -function Bullet() { - return ; -} -function SectionBlock({ title, children }: { title: string; children: React.ReactNode }) { - return ( - <> - -
      -

      {title}

      -
      {children}
      -
      - - ); -} export default function Terms({ downloadUrls }: Props) { return ( @@ -56,7 +43,7 @@ export default function Terms({ downloadUrls }: Props) {

      - +
      • "TablePro", "we", "us", "our" refers to the TablePro project and its maintainers.
      • "Application" means the TablePro desktop and mobile software, including all updates and plugins.
      • @@ -65,9 +52,9 @@ export default function Terms({ downloadUrls }: Props) {
      • "License Key" means the credential that activates paid features in the Application.
      • "You", "your" means the individual or entity using the Application or Services.
      -
      + - +

      The Application source code is licensed under the{' '} GNU Affero General Public License v3 (AGPLv3). @@ -77,9 +64,9 @@ export default function Terms({ downloadUrls }: Props) {

      The free tier covers core functionality and is enough for most personal and commercial use. No registration is required to use the free tier.

      -
      + - +

      Some advanced features require a paid License Key. When you purchase a License Key, you receive a non-exclusive, non-transferable right to enable those features for the number of devices and the duration covered by your plan.

      @@ -90,15 +77,15 @@ export default function Terms({ downloadUrls }: Props) { Refund eligibility is described in the{' '} Refund Policy.

      -
      + - +

      License Keys are managed through an account portal accessed by magic link sent to the email address you used at purchase. You are responsible for keeping that email account secure. We are not responsible for unauthorized access to your account caused by a compromised email account.

      -
      + - +

      You agree not to:

      • Use the Application or Services in violation of any law or third-party right.
      • @@ -107,9 +94,9 @@ export default function Terms({ downloadUrls }: Props) {
      • Use the Services to transmit malware or carry out denial-of-service attacks.
      • Resell or redistribute License Keys without our written permission.
      -
      + - +

      You are solely responsible for:

      • Securing your database credentials and License Keys.
      • @@ -121,18 +108,18 @@ export default function Terms({ downloadUrls }: Props) { You acknowledge and agree that your use of the Application can affect your databases, and you accept{' '} sole responsibility for any errors, malfunctions, or corruption of any database caused by your use of the Application, including queries you run, scripts you execute, and changes you commit.

        - + - +

        THE APPLICATION AND SERVICES ARE PROVIDED "AS IS" AND "AS AVAILABLE" WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, ACCURACY, OR UNINTERRUPTED OPERATION.

        We do not warrant that the Application will meet your requirements, be compatible with any specific database version, operate without error, or that any errors will be corrected.

        -
        + - +

        TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT SHALL TABLEPRO OR ITS CONTRIBUTORS BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, EXEMPLARY, OR PUNITIVE DAMAGES, INCLUDING BUT NOT LIMITED TO: LOSS OF DATA, LOSS OF DATABASE CONTENTS, DATA CORRUPTION, LOSS OF PROFITS, LOSS OF BUSINESS, BUSINESS INTERRUPTION, LOSS OF GOODWILL, OR THE COST OF SUBSTITUTE GOODS OR SERVICES, ARISING OUT OF OR RELATED TO YOUR USE OF THE APPLICATION OR SERVICES, EVEN IF WE HAVE BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.

        @@ -142,74 +129,74 @@ export default function Terms({ downloadUrls }: Props) {

        Some jurisdictions do not allow the exclusion or limitation of certain damages. In such jurisdictions, our liability is limited to the minimum extent permitted by law.

        -
        + - +

        You agree to indemnify and hold harmless TablePro and its contributors from any claim, demand, loss, or damage (including reasonable legal fees) arising out of your use of the Application or Services in violation of these Terms or any third-party right.

        -
        + - +

        We may release updates, fixes, and new features at our discretion. We have no obligation to maintain, update, or provide support for any version of the Application. Older versions may stop receiving security updates.

        Some Services (license validation, update checks) require network access to function. We do not guarantee continuous availability of these Services and may discontinue them with reasonable notice.

        -
        + - +

        You may stop using the Application and Services at any time. We may suspend or terminate your access to paid features without refund if you breach these Terms, including the Acceptable Use clause.

        Sections that by their nature survive termination (Disclaimer, Limitation of Liability, Indemnification, Governing Law) will continue to apply after termination.

        -
        + - +

        Our handling of personal data is described in the{' '} Privacy Policy. By using the Application or Services, you agree to that policy.

        -
        + - +

        We may update these Terms from time to time. Material changes will be announced on this page with a new "Last updated" date. Continuing to use the Application or Services after the update means you accept the new Terms.

        -
        + - +

        These Terms are governed by the laws of the Socialist Republic of Vietnam, without regard to conflict of law principles. Any dispute arising out of or relating to these Terms or your use of the Application or Services shall be resolved in the competent courts of Vietnam.

        If you reside in a jurisdiction with mandatory consumer protection laws, those laws apply to the extent they cannot be waived.

        -
        + - +

        If any provision of these Terms is held invalid or unenforceable by a court of competent jurisdiction, the remaining provisions remain in full force and effect.

        -
        + - +

        These Terms, together with the AGPLv3 license, the Privacy Policy, and the Refund Policy, constitute the entire agreement between you and TablePro regarding the Application and Services and supersede any prior agreements.

        -
        + - +

        For questions about these Terms, email{' '} hello@tablepro.app{' '} or open an issue at{' '} github.com/TableProApp/TablePro/issues.

        -
        + From 41588d31264a8ce047c1490c0f01a4be57867422 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 15 Aug 2026 10:53:08 +0700 Subject: [PATCH 3/5] fix(landing): stop fetching both themes, and make code blocks reachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Four screenshot blocks fetched every variant.** Compare and DatabaseClient drew their product shots as a pair of `` elements toggled with `hidden dark:contents`. `display: none` does not cancel an in-flight image request, so every visitor downloaded both the light and the dark file — `themed-image.tsx`'s own docblock says this is why it exists and why it selects on `prefers-color-scheme` instead. Those pages also skipped the WebP ladder that had already been generated for the same screenshots, so they shipped raw PNG where the homepage ships WebP. **Compare's comparison table was a grid of divs.** No table semantics, so nothing tied a cell to its column, and it drew its column separators at the row weight — in the one table where the columns carry the entire meaning. It is now a `DataTable`, matching the plan comparison, and its scroll container is reachable by keyboard. **Three scrolling code blocks could not be reached at all.** A container that scrolls but holds nothing focusable is unreachable from the keyboard, which is WCAG 2.1.1. The two scroll containers that *do* hold focusable children — the billing toggle and the category rail — are fine as they are and deliberately stay that way. Inline code had five treatments, one of them still filling with `bg-black/5 dark:bg-white/10` after the rule migration had removed every other raw alpha pair on the site. It has one now, and `rounded-key` is the radius token that exists for exactly this size of thing. --- .../js/components/landing/agents-mcp.tsx | 7 +- .../js/components/landing/footer-cta.tsx | 2 +- resources/js/components/ui/code.tsx | 50 ++++++++ resources/js/pages/Compare.tsx | 116 +++++++++--------- resources/js/pages/DatabaseClient.tsx | 72 ++++------- resources/js/pages/Download.tsx | 2 +- resources/js/pages/Privacy.tsx | 4 +- 7 files changed, 142 insertions(+), 111 deletions(-) create mode 100644 resources/js/components/ui/code.tsx diff --git a/resources/js/components/landing/agents-mcp.tsx b/resources/js/components/landing/agents-mcp.tsx index 104d315..8dc35f0 100644 --- a/resources/js/components/landing/agents-mcp.tsx +++ b/resources/js/components/landing/agents-mcp.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import { Check, Copy } from 'lucide-react'; import { toast } from 'sonner'; +import { CodeBlock } from '@/components/ui/code'; import Container from '@/components/ui/container'; import { FullLine } from '@/components/ui/full-line'; import { Ledger, LedgerRow } from '@/components/ui/ledger'; @@ -141,7 +142,8 @@ export default function AgentsMcp() {
      -
      +                        
      +                            
                                   
                                       {'{'}
                                       {'\n'}
      @@ -168,7 +170,8 @@ export default function AgentsMcp() {
                                       {'\n'}
                                       {'}'}
                                   
      -                        
      +
      +

      No token in the config. The bridge launches TablePro if it is not already running. diff --git a/resources/js/components/landing/footer-cta.tsx b/resources/js/components/landing/footer-cta.tsx index 20c89bb..791e88e 100644 --- a/resources/js/components/landing/footer-cta.tsx +++ b/resources/js/components/landing/footer-cta.tsx @@ -234,7 +234,7 @@ export default function FooterCTA() {

      - + {BREW_COMMAND} )} @@ -348,13 +349,13 @@ export default function Pricing({ paymentProvider, teamMinSeats }: { paymentProv
      -
      +
      {cycles.map((c) => (